接入 LinuxCNC TP 运行反馈

This commit is contained in:
2026-06-21 23:29:56 +08:00
parent 626bcfe8e3
commit 3771b9eafe
44 changed files with 7881 additions and 326 deletions

View File

@@ -1,4 +1,5 @@
#include <math.h> #include <math.h>
#include <stdarg.h>
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
#include <stdio.h> #include <stdio.h>
@@ -91,6 +92,76 @@ static int pose_near(const EmcPose *actual, const EmcPose *expected)
fabs(actual->tran.z - expected->tran.z) < 1e-9; fabs(actual->tran.z - expected->tran.z) < 1e-9;
} }
static const char *find_json_key(const char *cursor, const char *key)
{
if (!cursor || !key) {
return NULL;
}
char pattern[64];
snprintf(pattern, sizeof(pattern), "\"%s\"", key);
const char *match = strstr(cursor, pattern);
if (!match) {
return NULL;
}
match = strchr(match + strlen(pattern), ':');
return match ? match + 1 : NULL;
}
static double read_json_number_in_object(const char *object_start, const char *object_end, const char *key, double fallback)
{
const char *value = find_json_key(object_start, key);
if (!value || value >= object_end) {
return fallback;
}
char *end = NULL;
const double number = strtod(value, &end);
if (end == value || end > object_end || !isfinite(number)) {
return fallback;
}
return number;
}
static int read_json_string_in_object(
const char *object_start,
const char *object_end,
const char *key,
char *out,
size_t out_size)
{
const char *value = find_json_key(object_start, key);
if (!value || value >= object_end || out_size == 0) {
return 0;
}
while (value < object_end && (*value == ' ' || *value == '\t' || *value == '\n' || *value == '\r')) {
++value;
}
if (value >= object_end || *value != '"') {
return 0;
}
++value;
size_t index = 0;
while (value < object_end && *value && *value != '"' && index + 1 < out_size) {
out[index++] = *value++;
}
out[index] = '\0';
return index > 0;
}
static void appendf(char *out, size_t size, size_t *offset, const char *format, ...)
{
if (*offset >= size) {
return;
}
va_list args;
va_start(args, format);
const int written = vsnprintf(out + *offset, size - *offset, format, args);
va_end(args);
if (written > 0) {
*offset += (size_t)written;
}
}
static void append(char *out, size_t size, size_t *offset, const char *text) static void append(char *out, size_t size, size_t *offset, const char *text)
{ {
if (*offset >= size) { if (*offset >= size) {
@@ -102,6 +173,594 @@ static void append(char *out, size_t size, size_t *offset, const char *text)
} }
} }
typedef struct {
int index;
int line;
char type[32];
EmcPose end;
EmcPose start;
double feed_rate;
int plane;
double center_first;
double center_second;
int rotation;
double axis_end_point;
int zero_length;
int accepted;
int emitted;
} LctpMotionEvent;
typedef struct {
double cycle_time;
double max_velocity;
double max_acceleration;
double max_jerk;
double rapid_scale;
double feed_scale;
double tolerance;
int queue_size;
int max_cycles;
int sample_stride;
} LctpTimingOptions;
static LctpTimingOptions read_timing_options(const char *json)
{
LctpTimingOptions options;
options.cycle_time = read_json_number_in_object(json, json + strlen(json), "cycleTime", 0.001);
options.max_velocity = read_json_number_in_object(json, json + strlen(json), "maxVelocity", 35.0);
options.max_acceleration = read_json_number_in_object(json, json + strlen(json), "maxAcceleration", 500.0);
options.max_jerk = read_json_number_in_object(json, json + strlen(json), "maxJerk", 1000.0);
options.rapid_scale = read_json_number_in_object(json, json + strlen(json), "rapidScale", 1.0);
options.feed_scale = read_json_number_in_object(json, json + strlen(json), "feedScale", 1.0);
options.tolerance = read_json_number_in_object(json, json + strlen(json), "tolerance", 0.0);
options.queue_size = (int)read_json_number_in_object(json, json + strlen(json), "queueSize", TP_DEFAULT_QUEUE_SIZE);
options.max_cycles = (int)read_json_number_in_object(json, json + strlen(json), "maxCycles", 1000000);
options.sample_stride = (int)read_json_number_in_object(json, json + strlen(json), "sampleStride", 10.0);
if (!isfinite(options.cycle_time) || options.cycle_time <= 0.0) options.cycle_time = 0.001;
if (!isfinite(options.max_velocity) || options.max_velocity <= 0.0) options.max_velocity = 35.0;
if (!isfinite(options.max_acceleration) || options.max_acceleration <= 0.0) options.max_acceleration = 500.0;
if (!isfinite(options.max_jerk) || options.max_jerk <= 0.0) options.max_jerk = 1000.0;
if (!isfinite(options.rapid_scale) || options.rapid_scale <= 0.0) options.rapid_scale = 1.0;
if (!isfinite(options.feed_scale) || options.feed_scale <= 0.0) options.feed_scale = 1.0;
if (!isfinite(options.tolerance) || options.tolerance < 0.0) options.tolerance = 0.0;
if (options.queue_size <= 0) options.queue_size = TP_DEFAULT_QUEUE_SIZE;
if (options.max_cycles <= 0) options.max_cycles = 1000000;
if (options.sample_stride <= 0) options.sample_stride = 10;
return options;
}
static int parse_motion_events(const char *json, LctpMotionEvent *events, int max_events)
{
const char *motion = find_json_key(json, "motion");
if (!motion) {
return 0;
}
const char *cursor = strchr(motion, '[');
if (!cursor) {
return 0;
}
int count = 0;
EmcPose previous;
memset(&previous, 0, sizeof(previous));
while (count < max_events) {
const char *object_start = strchr(cursor, '{');
if (!object_start) {
break;
}
const char *object_end = strchr(object_start, '}');
if (!object_end) {
break;
}
LctpMotionEvent *event = &events[count];
memset(event, 0, sizeof(*event));
event->index = count;
event->line = (int)read_json_number_in_object(object_start, object_end, "line", -1.0);
event->feed_rate = read_json_number_in_object(object_start, object_end, "feedRate", 0.0);
event->plane = (int)read_json_number_in_object(object_start, object_end, "plane", 170.0);
event->center_first = read_json_number_in_object(object_start, object_end, "centerFirst", 0.0);
event->center_second = read_json_number_in_object(object_start, object_end, "centerSecond", 0.0);
event->rotation = (int)read_json_number_in_object(object_start, object_end, "rotation", 0.0);
event->axis_end_point = read_json_number_in_object(object_start, object_end, "axisEndPoint", 0.0);
event->start = previous;
read_json_string_in_object(object_start, object_end, "type", event->type, sizeof(event->type));
event->end.tran.x = read_json_number_in_object(object_start, object_end, "x", 0.0);
event->end.tran.y = read_json_number_in_object(object_start, object_end, "y", 0.0);
event->end.tran.z = read_json_number_in_object(object_start, object_end, "z", 0.0);
event->end.a = read_json_number_in_object(object_start, object_end, "a", 0.0);
event->end.b = read_json_number_in_object(object_start, object_end, "b", 0.0);
event->end.c = read_json_number_in_object(object_start, object_end, "c", 0.0);
event->end.u = read_json_number_in_object(object_start, object_end, "u", 0.0);
event->end.v = read_json_number_in_object(object_start, object_end, "v", 0.0);
event->end.w = read_json_number_in_object(object_start, object_end, "w", 0.0);
event->zero_length =
fabs(event->end.tran.x - event->start.tran.x) < TP_POS_EPSILON &&
fabs(event->end.tran.y - event->start.tran.y) < TP_POS_EPSILON &&
fabs(event->end.tran.z - event->start.tran.z) < TP_POS_EPSILON &&
fabs(event->end.a - event->start.a) < TP_POS_EPSILON &&
fabs(event->end.b - event->start.b) < TP_POS_EPSILON &&
fabs(event->end.c - event->start.c) < TP_POS_EPSILON &&
fabs(event->end.u - event->start.u) < TP_POS_EPSILON &&
fabs(event->end.v - event->start.v) < TP_POS_EPSILON &&
fabs(event->end.w - event->start.w) < TP_POS_EPSILON;
previous = event->end;
count += 1;
cursor = object_end + 1;
}
return count;
}
static struct state_tag_t tag_for_event(const LctpMotionEvent *event)
{
struct state_tag_t tag;
memset(&tag, 0, sizeof(tag));
tag.fields[GM_FIELD_LINE_NUMBER] = event->index + 1;
tag.fields_float[GM_FIELD_FLOAT_LINE_NUMBER] = (float)event->line;
tag.fields_float[GM_FIELD_FLOAT_FEED] = (float)event->feed_rate;
return tag;
}
static double event_velocity(const LctpMotionEvent *event, const LctpTimingOptions *options)
{
if (strcmp(event->type, "STRAIGHT_TRAVERSE") == 0) {
return options->max_velocity * options->rapid_scale;
}
const double feed_units_per_sec = event->feed_rate > 0.0 ? event->feed_rate / 60.0 : options->max_velocity;
const double scaled = feed_units_per_sec * options->feed_scale;
return fmin(fmax(scaled, 0.000001), options->max_velocity);
}
static PmCartesian arc_center_for_event(const LctpMotionEvent *event)
{
PmCartesian center;
memset(&center, 0, sizeof(center));
if (event->plane == 180) {
center.x = event->center_first;
center.z = event->center_second;
center.y = event->start.tran.y;
} else if (event->plane == 190) {
center.y = event->center_first;
center.z = event->center_second;
center.x = event->start.tran.x;
} else {
center.x = event->center_first;
center.y = event->center_second;
center.z = event->start.tran.z;
}
return center;
}
static PmCartesian arc_normal_for_event(const LctpMotionEvent *event)
{
PmCartesian normal;
memset(&normal, 0, sizeof(normal));
if (event->plane == 180) {
normal.y = 1.0;
} else if (event->plane == 190) {
normal.x = 1.0;
} else {
normal.z = 1.0;
}
return normal;
}
static int add_motion_event_to_tp(
TP_STRUCT *tp,
LctpMotionEvent *event,
const LctpTimingOptions *options)
{
const double vel = event_velocity(event, options);
struct state_tag_t tag = tag_for_event(event);
if (strcmp(event->type, "ARC_FEED") == 0) {
const PmCartesian center = arc_center_for_event(event);
const PmCartesian normal = arc_normal_for_event(event);
return tpAddCircle(
tp,
event->end,
center,
normal,
event->rotation,
EMC_MOTION_TYPE_ARC,
vel,
options->max_velocity,
options->max_acceleration,
options->max_jerk,
status.enables_new,
0,
tag);
}
const int motion_type = strcmp(event->type, "STRAIGHT_TRAVERSE") == 0
? EMC_MOTION_TYPE_TRAVERSE
: EMC_MOTION_TYPE_FEED;
return tpAddLine(
tp,
event->end,
motion_type,
vel,
options->max_velocity,
options->max_acceleration,
options->max_jerk,
status.enables_new,
0,
-1,
tag);
}
static void append_zero_length_segment(
char *out,
size_t out_size,
size_t *offset,
LctpMotionEvent *event,
int *emitted_segments,
int cycles,
const LctpTimingOptions *options)
{
appendf(out, out_size, offset,
"%s{\"index\":%d,\"line\":%d,\"type\":\"%s\","
"\"zeroLength\":true,\"tpCycleRc\":0,\"tpGetPos\":0,\"cycles\":0,"
"\"durationSeconds\":0,\"elapsedSeconds\":%.12g,"
"\"queueDepth\":0,\"activeDepth\":0,\"velocity\":0,"
"\"x\":%.12g,\"y\":%.12g,\"z\":%.12g,\"a\":%.12g,\"b\":%.12g,\"c\":%.12g}",
*emitted_segments == 0 ? "" : ",",
event->index,
event->line,
event->type,
cycles * options->cycle_time,
event->end.tran.x,
event->end.tran.y,
event->end.tran.z,
event->end.a,
event->end.b,
event->end.c);
event->emitted = 1;
*emitted_segments += 1;
}
static int enqueue_next_nonzero_event(
TP_STRUCT *tp,
LctpMotionEvent *events,
int event_count,
int *next_event,
const LctpTimingOptions *options,
int *accepted_events,
int *add_failures,
char *out,
size_t out_size,
size_t *offset,
int *emitted_segments,
int cycles)
{
while (*next_event < event_count) {
LctpMotionEvent *event = &events[*next_event];
*next_event += 1;
if (event->zero_length) {
append_zero_length_segment(out, out_size, offset, event, emitted_segments, cycles, options);
continue;
}
const int add_rc = add_motion_event_to_tp(tp, event, options);
if (add_rc == TP_ERR_OK) {
event->accepted = 1;
*accepted_events += 1;
} else if (add_rc == TP_ERR_ZERO_LENGTH) {
event->zero_length = 1;
append_zero_length_segment(out, out_size, offset, event, emitted_segments, cycles, options);
} else {
*add_failures += 1;
}
return add_rc;
}
return TP_ERR_NO_ACTION;
}
static void append_runtime_sample(
char *samples,
size_t samples_size,
size_t *samples_offset,
int *sample_count,
int cycles,
int motion_index,
const LctpMotionEvent *event,
const LctpTimingOptions *options,
const TP_STRUCT *tp)
{
EmcPose pos;
memset(&pos, 0, sizeof(pos));
tpGetPos(tp, &pos);
appendf(samples, samples_size, samples_offset,
"%s{\"sampleIndex\":%d,\"cycle\":%d,\"timeSeconds\":%.12g,"
"\"motionIndex\":%d,\"line\":%d,\"type\":\"%s\","
"\"x\":%.12g,\"y\":%.12g,\"z\":%.12g,\"a\":%.12g,\"b\":%.12g,\"c\":%.12g,"
"\"u\":%.12g,\"v\":%.12g,\"w\":%.12g,"
"\"currentVelocity\":%.12g,\"requestedVelocity\":%.12g,"
"\"distanceToGo\":%.12g,"
"\"dtgX\":%.12g,\"dtgY\":%.12g,\"dtgZ\":%.12g,"
"\"queueDepth\":%d,\"activeDepth\":%d}",
*sample_count == 0 ? "" : ",",
*sample_count,
cycles,
cycles * options->cycle_time,
motion_index,
event ? event->line : -1,
event ? event->type : "-",
pos.tran.x,
pos.tran.y,
pos.tran.z,
pos.a,
pos.b,
pos.c,
pos.u,
pos.v,
pos.w,
status.current_vel,
status.requested_vel,
status.distance_to_go,
status.dtg.tran.x,
status.dtg.tran.y,
status.dtg.tran.z,
tpQueueDepth((TP_STRUCT *)tp),
tpActiveDepth((TP_STRUCT *)tp));
*sample_count += 1;
}
EMSCRIPTEN_KEEPALIVE
char *lctp_run_canonical_motion_timing(const char *json)
{
const size_t out_size = 4 * 1024 * 1024;
char *out = (char *)malloc(out_size);
if (!out) {
return NULL;
}
out[0] = '\0';
size_t offset = 0;
char *samples = (char *)malloc(out_size);
if (!samples) {
free(out);
return NULL;
}
samples[0] = '\0';
size_t samples_offset = 0;
int sample_count = 0;
if (!json) {
append(out, out_size, &offset, "{\"ok\":false,\"error\":\"missing canonical motion JSON\"}\n");
free(samples);
return out;
}
enum { MAX_EVENTS = 4096 };
LctpMotionEvent *events = (LctpMotionEvent *)calloc(MAX_EVENTS, sizeof(LctpMotionEvent));
if (!events) {
append(out, out_size, &offset, "{\"ok\":false,\"error\":\"failed to allocate motion events\"}\n");
free(samples);
return out;
}
const LctpTimingOptions options = read_timing_options(json);
const int event_count = parse_motion_events(json, events, MAX_EVENTS);
if (event_count <= 0) {
free(events);
append(out, out_size, &offset, "{\"ok\":false,\"error\":\"no canonical motion events\"}\n");
free(samples);
return out;
}
init_motion_state();
status.net_feed_scale = options.feed_scale;
status.feed_scale = options.feed_scale;
status.rapid_scale = options.rapid_scale;
TP_STRUCT tp;
memset(&tp, 0, sizeof(tp));
EmcPose start;
memset(&start, 0, sizeof(start));
const int create_rc = tpCreate(&tp, options.queue_size, 100);
const int set_cycle_rc = tpSetCycleTime(&tp, options.cycle_time);
const int set_pos_rc = tpSetPos(&tp, &start);
const int set_vmax_rc = tpSetVmax(&tp, options.max_velocity, options.max_velocity);
const int set_vlimit_rc = tpSetVlimit(&tp, options.max_velocity);
const int set_amax_rc = tpSetAmax(&tp, options.max_acceleration);
const int set_term_rc = tpSetTermCond(
&tp,
options.tolerance > 0.0 ? TC_TERM_COND_PARABOLIC : TC_TERM_COND_STOP,
options.tolerance);
int add_failures = 0;
int next_event = 0;
int accepted_events = 0;
appendf(out, out_size, &offset,
"{\"apiName\":\"linuxcnc_tp_queue_timing\","
"\"semanticBoundary\":\"linuxcnc_tp_queue_runtime_timing_from_canonical_motion\","
"\"ok\":true,"
"\"tpCreate\":%d,"
"\"tpSetCycleTime\":%d,"
"\"tpSetPos\":%d,"
"\"tpSetVmax\":%d,"
"\"tpSetVlimit\":%d,"
"\"tpSetAmax\":%d,"
"\"tpSetTermCond\":%d,"
"\"cycleTime\":%.12g,"
"\"motionCount\":%d,"
"\"segments\":[",
create_rc,
set_cycle_rc,
set_pos_rc,
set_vmax_rc,
set_vlimit_rc,
set_amax_rc,
set_term_rc,
options.cycle_time,
event_count);
int cycles = 0;
int cycle_rc = 0;
int current_segment = -1;
int emitted_segments = 0;
int segment_start_cycle = 0;
int segment_last_cycle = 0;
while (next_event < event_count && tpQueueDepth(&tp) < options.queue_size) {
enqueue_next_nonzero_event(
&tp,
events,
event_count,
&next_event,
&options,
&accepted_events,
&add_failures,
out,
out_size,
&offset,
&emitted_segments,
cycles);
}
while (cycles < options.max_cycles && (!tpIsDone(&tp) || next_event < event_count)) {
while (next_event < event_count && tpQueueDepth(&tp) < options.queue_size) {
enqueue_next_nonzero_event(
&tp,
events,
event_count,
&next_event,
&options,
&accepted_events,
&add_failures,
out,
out_size,
&offset,
&emitted_segments,
cycles);
}
cycle_rc = tpRunCycle(&tp, (long)llround(options.cycle_time * 1000000000.0));
cycles += 1;
const struct state_tag_t exec_tag = tpGetExecTag(&tp);
const int exec_segment = exec_tag.fields[GM_FIELD_LINE_NUMBER] - 1;
if (exec_segment >= 0 && exec_segment < event_count) {
if (
sample_count == 0 ||
cycles % options.sample_stride == 0 ||
exec_segment != current_segment
) {
append_runtime_sample(
samples,
out_size,
&samples_offset,
&sample_count,
cycles,
exec_segment,
&events[exec_segment],
&options,
&tp);
}
if (current_segment >= 0 && exec_segment != current_segment) {
LctpMotionEvent *completed = &events[current_segment];
EmcPose pos;
memset(&pos, 0, sizeof(pos));
const int get_pos_rc = tpGetPos(&tp, &pos);
const double vel = event_velocity(completed, &options);
appendf(out, out_size, &offset,
"%s{\"index\":%d,\"line\":%d,\"type\":\"%s\","
"\"tpCycleRc\":%d,\"tpGetPos\":%d,\"cycles\":%d,"
"\"durationSeconds\":%.12g,\"elapsedSeconds\":%.12g,"
"\"queueDepth\":%d,\"activeDepth\":%d,\"velocity\":%.12g,"
"\"x\":%.12g,\"y\":%.12g,\"z\":%.12g,\"a\":%.12g,\"b\":%.12g,\"c\":%.12g}",
emitted_segments == 0 ? "" : ",",
current_segment,
completed->line,
completed->type,
cycle_rc,
get_pos_rc,
segment_last_cycle - segment_start_cycle + 1,
(segment_last_cycle - segment_start_cycle + 1) * options.cycle_time,
segment_last_cycle * options.cycle_time,
tpQueueDepth(&tp),
tpActiveDepth(&tp),
vel,
pos.tran.x,
pos.tran.y,
pos.tran.z,
pos.a,
pos.b,
pos.c);
emitted_segments += 1;
segment_start_cycle = cycles;
}
if (exec_segment != current_segment) {
current_segment = exec_segment;
segment_start_cycle = cycles;
}
segment_last_cycle = cycles;
}
if (cycle_rc < 0) {
break;
}
}
if (current_segment >= 0) {
append_runtime_sample(
samples,
out_size,
&samples_offset,
&sample_count,
cycles,
current_segment,
&events[current_segment],
&options,
&tp);
LctpMotionEvent *completed = &events[current_segment];
EmcPose pos;
memset(&pos, 0, sizeof(pos));
const int get_pos_rc = tpGetPos(&tp, &pos);
const double vel = event_velocity(completed, &options);
appendf(out, out_size, &offset,
"%s{\"index\":%d,\"line\":%d,\"type\":\"%s\","
"\"tpCycleRc\":%d,\"tpGetPos\":%d,\"cycles\":%d,"
"\"durationSeconds\":%.12g,\"elapsedSeconds\":%.12g,"
"\"queueDepth\":%d,\"activeDepth\":%d,\"velocity\":%.12g,"
"\"x\":%.12g,\"y\":%.12g,\"z\":%.12g,\"a\":%.12g,\"b\":%.12g,\"c\":%.12g}",
emitted_segments == 0 ? "" : ",",
current_segment,
completed->line,
completed->type,
cycle_rc,
get_pos_rc,
segment_last_cycle - segment_start_cycle + 1,
(segment_last_cycle - segment_start_cycle + 1) * options.cycle_time,
segment_last_cycle * options.cycle_time,
tpQueueDepth(&tp),
tpActiveDepth(&tp),
vel,
pos.tran.x,
pos.tran.y,
pos.tran.z,
pos.a,
pos.b,
pos.c);
emitted_segments += 1;
}
appendf(out, out_size, &offset,
"],\"samples\":[%s],\"sampleCount\":%d,"
"\"acceptedMotionCount\":%d,\"emittedMotionCount\":%d,\"totalCycles\":%d,"
"\"totalSeconds\":%.12g,\"addFailures\":%d,\"tpDone\":%d,\"finalQueueDepth\":%d}\n",
samples,
sample_count,
accepted_events,
emitted_segments,
cycles,
cycles * options.cycle_time,
add_failures,
tpIsDone(&tp),
tpQueueDepth(&tp));
free(events);
free(samples);
return out;
}
static void append_linear_probe(char *out, size_t size, size_t *offset) static void append_linear_probe(char *out, size_t size, size_t *offset)
{ {
init_motion_state(); init_motion_state();

View File

@@ -0,0 +1,228 @@
import createLinuxCncTpModule from "../../../build/wasm/tp/linuxcnc_tp.js";
const SEMANTIC_BOUNDARY = "linuxcnc_tp_queue_runtime_timing_from_canonical_motion";
function allocCString(mod, value) {
const bytes = mod.lengthBytesUTF8(value) + 1;
const ptr = mod._malloc(bytes);
mod.stringToUTF8(value, ptr, bytes);
return ptr;
}
function callTiming(mod, payload) {
const inputPtr = allocCString(mod, JSON.stringify(payload));
let resultPtr = 0;
try {
resultPtr = mod._lctp_run_canonical_motion_timing(inputPtr);
if (!resultPtr) {
throw new Error("lctp_run_canonical_motion_timing returned null");
}
const text = mod.UTF8ToString(resultPtr);
const result = JSON.parse(text);
if (result.ok !== true) {
throw new Error(result.error || "LinuxCNC TP queue timing failed");
}
return result;
} finally {
if (resultPtr) mod._lctp_free_string(resultPtr);
mod._free(inputPtr);
}
}
export async function createLinuxCncTpSdk(moduleOptions = {}) {
const mod = await createLinuxCncTpModule(moduleOptions);
return {
apiName: "linuxcnc-tp-wasm-sdk",
semanticBoundary: SEMANTIC_BOUNDARY,
module: mod,
readiness() {
return {
apiName: "linuxcnc-tp-wasm-sdk-readiness",
loaded: true,
semanticBoundary: SEMANTIC_BOUNDARY,
runCanonicalMotionTimingReady: typeof mod._lctp_run_canonical_motion_timing === "function",
};
},
runCanonicalMotionTiming({ motion = [], options = {} } = {}) {
const result = callTiming(mod, {
...options,
motion: motion.map((event, index) => ({
index,
line: Number.isFinite(Number(event.line)) ? Number(event.line) : -1,
type: event.type,
feedRate: Number.isFinite(Number(event.feedRate)) ? Number(event.feedRate) : 0,
x: numberOrZero(event.axes?.x),
y: numberOrZero(event.axes?.y),
z: numberOrZero(event.axes?.z),
a: numberOrZero(event.axes?.a),
b: numberOrZero(event.axes?.b),
c: numberOrZero(event.axes?.c),
u: numberOrZero(event.axes?.u),
v: numberOrZero(event.axes?.v),
w: numberOrZero(event.axes?.w),
plane: numberOrZero(event.axes?.arc?.plane),
centerFirst: numberOrZero(event.axes?.arc?.centerFirst),
centerSecond: numberOrZero(event.axes?.arc?.centerSecond),
rotation: numberOrZero(event.axes?.arc?.rotation),
axisEndPoint: numberOrZero(event.axes?.arc?.axisEndPoint),
})),
});
return normalizeTimingResult(result, motion);
},
};
}
function normalizeTimingResult(result, motion) {
const rawSegmentsByIndex = new Map((result.segments || []).map((segment) => [segment.index, segment]));
let elapsedSeconds = 0;
const segments = motion.map((event, index) => {
const raw = rawSegmentsByIndex.get(index);
const durationSeconds = Math.max(Number(raw?.durationSeconds) || 0, 0);
elapsedSeconds += durationSeconds;
return {
index,
line: event.line ?? null,
type: event.type,
motionClass: event.type === "STRAIGHT_TRAVERSE" ? "rapid" : "feed",
durationSeconds,
elapsedSeconds,
currentVelocity: Number(raw?.velocity) || 0,
velocityMmPerMin: (Number(raw?.velocity) || 0) * 60,
queueDepth: Number(raw?.queueDepth) || 0,
activeDepth: Number(raw?.activeDepth) || 0,
cycles: Number(raw?.cycles) || 0,
axes: event.axes || {},
runtimeAxes: axesFromRuntimeRecord(raw, event.axes || {}),
tp: raw || null,
};
});
const rawSamples = Array.isArray(result.samples) && result.samples.length > 0
? result.samples
: synthesizeSamplesFromSegments(result.segments || []);
const samples = normalizeSamples(rawSamples);
const feedSeconds = segments
.filter((segment) => segment.motionClass === "feed")
.reduce((total, segment) => total + segment.durationSeconds, 0);
const rapidSeconds = segments
.filter((segment) => segment.motionClass === "rapid")
.reduce((total, segment) => total + segment.durationSeconds, 0);
return {
apiName: "web-rtcp-5axis-linuxcnc-tp-program-timing",
semanticBoundary: SEMANTIC_BOUNDARY,
sourceBasis: "LinuxCNC interpreter canonical motion events queued through LinuxCNC src/emc/tp WASM",
plannerRuntimeReady: result.ok === true
&& result.addFailures === 0
&& result.tpDone === 1
&& segments.length === motion.length,
sampleCount: Number(result.sampleCount) || samples.length,
samples,
totalSeconds: elapsedSeconds,
totalMinutes: elapsedSeconds / 60,
feedSeconds,
rapidSeconds,
motionCount: motion.length,
emittedMotionCount: result.emittedMotionCount || 0,
acceptedMotionCount: result.acceptedMotionCount || 0,
totalCycles: result.totalCycles || 0,
cycleTime: result.cycleTime || 0.001,
addFailures: result.addFailures || 0,
tpDone: result.tpDone === 1,
finalQueueDepth: result.finalQueueDepth || 0,
segments,
raw: result,
};
}
function normalizeSamples(samples) {
return samples.map((sample, index) => ({
sampleIndex: Number(sample.sampleIndex ?? index),
cycle: Number(sample.cycle) || 0,
timeSeconds: Number(sample.timeSeconds) || 0,
motionIndex: Number(sample.motionIndex) || 0,
line: Number.isFinite(Number(sample.line)) ? Number(sample.line) : null,
type: sample.type || "-",
axes: {
x: numberOrZero(sample.x),
y: numberOrZero(sample.y),
z: numberOrZero(sample.z),
a: numberOrZero(sample.a),
b: numberOrZero(sample.b),
c: numberOrZero(sample.c),
u: numberOrZero(sample.u),
v: numberOrZero(sample.v),
w: numberOrZero(sample.w),
},
currentVelocity: numberOrZero(sample.currentVelocity),
currentVelocityMmPerMin: numberOrZero(sample.currentVelocity) * 60,
requestedVelocity: numberOrZero(sample.requestedVelocity),
requestedVelocityMmPerMin: numberOrZero(sample.requestedVelocity) * 60,
distanceToGo: numberOrZero(sample.distanceToGo),
dtg: {
x: numberOrZero(sample.dtgX),
y: numberOrZero(sample.dtgY),
z: numberOrZero(sample.dtgZ),
},
queueDepth: Number(sample.queueDepth) || 0,
activeDepth: Number(sample.activeDepth) || 0,
}));
}
function synthesizeSamplesFromSegments(segments) {
return segments.map((segment, index) => ({
sampleIndex: index,
cycle: Number(segment.cycles) || 0,
timeSeconds: Number(segment.elapsedSeconds) || 0,
motionIndex: Number(segment.index) || index,
line: segment.line,
type: segment.type,
x: segment.x,
y: segment.y,
z: segment.z,
a: segment.a,
b: segment.b,
c: segment.c,
u: segment.u,
v: segment.v,
w: segment.w,
currentVelocity: segment.velocity,
requestedVelocity: segment.velocity,
distanceToGo: segment.distanceToGo ?? 0,
dtgX: segment.dtgX ?? 0,
dtgY: segment.dtgY ?? 0,
dtgZ: segment.dtgZ ?? 0,
queueDepth: segment.queueDepth,
activeDepth: segment.activeDepth,
}));
}
function axesFromRuntimeRecord(record, fallback = {}) {
return {
x: numberOrFallback(record?.x, fallback.x, 0),
y: numberOrFallback(record?.y, fallback.y, 0),
z: numberOrFallback(record?.z, fallback.z, 0),
a: numberOrFallback(record?.a, fallback.a, 0),
b: numberOrFallback(record?.b, fallback.b, 0),
c: numberOrFallback(record?.c, fallback.c, 0),
u: numberOrFallback(record?.u, fallback.u, 0),
v: numberOrFallback(record?.v, fallback.v, 0),
w: numberOrFallback(record?.w, fallback.w, 0),
};
}
function numberOrFallback(...values) {
for (const value of values) {
const number = Number(value);
if (Number.isFinite(number)) return number;
}
return 0;
}
function numberOrZero(value) {
const number = Number(value);
return Number.isFinite(number) ? number : 0;
}

View File

@@ -80,4 +80,84 @@ try {
tp._lctp_free_string(resultPtr); tp._lctp_free_string(resultPtr);
} }
function allocCString(mod, value) {
const bytes = mod.lengthBytesUTF8(value) + 1;
const ptr = mod._malloc(bytes);
mod.stringToUTF8(value, ptr, bytes);
return ptr;
}
const timingPayload = JSON.stringify({
cycleTime: 0.001,
maxVelocity: 10,
maxAcceleration: 50,
maxJerk: 100,
queueSize: 8,
motion: [
{ line: 2, type: "STRAIGHT_TRAVERSE", x: 0, y: 0, z: 0, a: 0, b: 0, c: 0, feedRate: 0 },
{ line: 3, type: "STRAIGHT_FEED", x: 1, y: 0, z: 0, a: 0, b: 0, c: 0, feedRate: 60 },
{ line: 4, type: "STRAIGHT_FEED", x: 1, y: 1, z: 0, a: 0, b: 0, c: 0, feedRate: 60 },
],
});
const timingInputPtr = allocCString(tp, timingPayload);
const timingResultPtr = tp._lctp_run_canonical_motion_timing(timingInputPtr);
assert.notEqual(timingResultPtr, 0);
try {
const timing = JSON.parse(tp.UTF8ToString(timingResultPtr));
assert.equal(timing.ok, true);
assert.equal(timing.semanticBoundary, "linuxcnc_tp_queue_runtime_timing_from_canonical_motion");
assert.equal(timing.motionCount, 3);
assert.equal(timing.addFailures, 0);
assert.equal(timing.emittedMotionCount, 3);
assert.equal(timing.segments[0].zeroLength, true);
assert.equal(timing.segments[0].durationSeconds, 0);
assert.equal(timing.totalSeconds > 0, true);
assert.equal(timing.tpDone, 1);
} finally {
tp._lctp_free_string(timingResultPtr);
tp._free(timingInputPtr);
}
const arcPayload = JSON.stringify({
cycleTime: 0.001,
maxVelocity: 10,
maxAcceleration: 50,
maxJerk: 100,
queueSize: 8,
motion: [
{ line: 2, type: "STRAIGHT_TRAVERSE", x: 1, y: 0, z: 0, a: 0, b: 0, c: 0, feedRate: 0 },
{
line: 3,
type: "ARC_FEED",
x: 0,
y: 1,
z: 0,
a: 0,
b: 0,
c: 0,
feedRate: 60,
plane: 170,
centerFirst: 0,
centerSecond: 0,
rotation: -1,
axisEndPoint: 0,
},
],
});
const arcInputPtr = allocCString(tp, arcPayload);
const arcResultPtr = tp._lctp_run_canonical_motion_timing(arcInputPtr);
assert.notEqual(arcResultPtr, 0);
try {
const timing = JSON.parse(tp.UTF8ToString(arcResultPtr));
assert.equal(timing.ok, true);
assert.equal(timing.addFailures, 0);
assert.equal(timing.emittedMotionCount, 2);
assert.equal(timing.segments[1].type, "ARC_FEED");
assert.equal(timing.segments[1].durationSeconds > 1.4, true);
assert.equal(timing.tpDone, 1);
} finally {
tp._lctp_free_string(arcResultPtr);
tp._free(arcInputPtr);
}
console.log("tp_wasm_node_smoke=ok"); console.log("tp_wasm_node_smoke=ok");

View File

@@ -104,5 +104,5 @@ link_wasm_module \
-s ENVIRONMENT=web,node \ -s ENVIRONMENT=web,node \
-s ALLOW_MEMORY_GROWTH=1 \ -s ALLOW_MEMORY_GROWTH=1 \
-s NO_EXIT_RUNTIME=1 \ -s NO_EXIT_RUNTIME=1 \
-s EXPORTED_FUNCTIONS='["_malloc","_free","_lctp_run_probe","_lctp_free_string"]' \ -s EXPORTED_FUNCTIONS='["_malloc","_free","_lctp_run_probe","_lctp_run_canonical_motion_timing","_lctp_free_string"]' \
-s EXPORTED_RUNTIME_METHODS='["UTF8ToString"]' -s EXPORTED_RUNTIME_METHODS='["UTF8ToString","stringToUTF8","lengthBytesUTF8"]'

View File

@@ -6,8 +6,8 @@
"scripts": { "scripts": {
"build": "node scripts/build-static.mjs", "build": "node scripts/build-static.mjs",
"dev": "python3 -m http.server 4173", "dev": "python3 -m http.server 4173",
"smoke": "bash ../tests/browser/verify_gmoccapy_shell_browser.sh", "smoke": "bash ../tests/browser/verify_gmoccapy_shell_browser.sh && bash ../tests/browser/verify_gmoccapy_dist_browser.sh",
"smoke:node": "node ../tests/node/verify_linuxcnc_kinematics_runtime.mjs && node ../tests/node/verify_rtcp_store.mjs && node ../tests/node/verify_profile_boundary.mjs" "smoke:node": "node ../tests/node/verify_linuxcnc_kinematics_runtime.mjs && node ../tests/node/verify_linuxcnc_interpreter_runtime.mjs && node ../tests/node/verify_linuxcnc_ini_runtime.mjs && node ../tests/node/verify_full_linuxcnc_5axis_source.mjs && node ../tests/node/verify_full_execution_boundary.mjs && node ../tests/node/verify_machine_file_staging.mjs && node ../tests/node/verify_five_axis_session.mjs && node ../tests/node/verify_rtcp_store.mjs && node ../tests/node/verify_profile_boundary.mjs"
}, },
"dependencies": {}, "dependencies": {},
"devDependencies": {} "devDependencies": {}

View File

@@ -1,14 +1,20 @@
import { cp, mkdir, rm, readFile } from "node:fs/promises"; import { cp, mkdir, readdir, rm, readFile } from "node:fs/promises";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
const appRoot = dirname(dirname(fileURLToPath(import.meta.url))); const appRoot = dirname(dirname(fileURLToPath(import.meta.url)));
const repoRoot = dirname(dirname(appRoot));
const distDir = join(appRoot, "dist"); const distDir = join(appRoot, "dist");
await rm(distDir, { recursive: true, force: true }); await rm(distDir, { recursive: true, force: true });
await mkdir(distDir, { recursive: true }); await mkdir(distDir, { recursive: true });
await cp(join(appRoot, "index.html"), join(distDir, "index.html")); await cp(join(appRoot, "index.html"), join(distDir, "index.html"));
await cp(join(appRoot, "src"), join(distDir, "src"), { recursive: true }); await cp(join(appRoot, "src"), join(distDir, "src"), { recursive: true });
await copyLinuxCncManifest();
await copyLinuxCncConfigAssets();
await copyKinematicsRuntimeAssets();
await copyInterpreterRuntimeAssets();
await copyTpRuntimeAssets();
const packageJson = JSON.parse(await readFile(join(appRoot, "package.json"), "utf8")); const packageJson = JSON.parse(await readFile(join(appRoot, "package.json"), "utf8"));
const forbiddenDependencies = ["react", "vue", "@angular/core", "svelte"]; const forbiddenDependencies = ["react", "vue", "@angular/core", "svelte"];
@@ -22,4 +28,59 @@ if (forbidden.length > 0) {
throw new Error(`forbidden frontend framework dependency detected: ${forbidden.join(", ")}`); throw new Error(`forbidden frontend framework dependency detected: ${forbidden.join(", ")}`);
} }
async function copyInterpreterRuntimeAssets() {
const coreSrcDir = join(repoRoot, "wasm-port/build/wasm/core");
const coreDistDir = join(distDir, "wasm-port/build/wasm/core");
await mkdir(coreDistDir, { recursive: true });
for (const entry of ["linuxcnc_interp.js", "linuxcnc_interp.wasm"]) {
await cp(join(coreSrcDir, entry), join(coreDistDir, entry));
}
}
async function copyTpRuntimeAssets() {
const tpSrcDir = join(repoRoot, "wasm-port/build/wasm/tp");
const tpDistDir = join(distDir, "wasm-port/build/wasm/tp");
await mkdir(tpDistDir, { recursive: true });
for (const entry of ["linuxcnc_tp.js", "linuxcnc_tp.wasm"]) {
await cp(join(tpSrcDir, entry), join(tpDistDir, entry));
}
}
console.log("gmoccapy_static_build=ok"); console.log("gmoccapy_static_build=ok");
async function copyLinuxCncConfigAssets() {
const configSrcDir = join(repoRoot, "wasm-port/vendor/linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting");
const configDistDir = join(distDir, "configs/sim/axis/vismach/5axis/table-rotary-tilting");
await mkdir(configDistDir, { recursive: true });
await cp(configSrcDir, configDistDir, { recursive: true });
}
async function copyLinuxCncManifest() {
const manifestDistDir = join(distDir, "wasm-port/tools");
await mkdir(manifestDistDir, { recursive: true });
await cp(
join(repoRoot, "wasm-port/tools/source-manifest.txt"),
join(manifestDistDir, "source-manifest.txt"),
);
}
async function copyKinematicsRuntimeAssets() {
const sdkSrcDir = join(repoRoot, "wasm-port/runtime/sdk/src");
const sdkDistDir = join(distDir, "wasm-port/runtime/sdk/src");
await mkdir(sdkDistDir, { recursive: true });
await cp(
join(sdkSrcDir, "linuxcnc-kinematics.js"),
join(sdkDistDir, "linuxcnc-kinematics.js"),
);
for (const entry of ["linuxcnc-interp.js", "linuxcnc-hal.js", "linuxcnc-tp.js"]) {
await cp(join(sdkSrcDir, entry), join(sdkDistDir, entry));
}
const kinematicsSrcDir = join(repoRoot, "wasm-port/build/wasm/kinematics");
const kinematicsDistDir = join(distDir, "wasm-port/build/wasm/kinematics");
await mkdir(kinematicsDistDir, { recursive: true });
for (const entry of await readdir(kinematicsSrcDir)) {
if (!/^linuxcnc_.*_kinematics\.(js|wasm)$/.test(entry)) continue;
await cp(join(kinematicsSrcDir, entry), join(kinematicsDistDir, entry));
}
}

View File

@@ -1,5 +1,10 @@
import { createSimulationStore } from "./state/store.js"; import { createSimulationStore } from "./state/store.js";
import { mountGmoccapyShell } from "./ui/gmoccapy-shell.js"; import { mountGmoccapyShell } from "./ui/gmoccapy-shell.js";
import { createLinuxCncInterpreterRuntime } from "./runtime/linuxcnc-interpreter-runtime.js";
import { createLinuxCncInterpreterWorkerRuntime } from "./runtime/linuxcnc-interpreter-worker-client.js";
import { createLinuxCncKinematicsRuntime } from "./runtime/linuxcnc-kinematics-runtime.js";
import { createLinuxCncKinematicsWorkerRuntime } from "./runtime/linuxcnc-kinematics-worker-client.js";
import { loadLinuxCncIniConfig } from "./runtime/linuxcnc-ini-runtime.js";
const app = document.querySelector("#app"); const app = document.querySelector("#app");
@@ -9,11 +14,134 @@ if (!app) {
const store = createSimulationStore(); const store = createSimulationStore();
const shell = mountGmoccapyShell(app, store); const shell = mountGmoccapyShell(app, store);
const iniConfigReady = attachProfileIniConfig(store, store.getState().profile);
const kinematicsRuntimeReady = attachDefaultKinematicsRuntime(store, store.getState().profile.kinematicsModuleId || store.getState().machineProfile);
const interpreterRuntimeReady = attachDefaultInterpreterRuntime(store);
let attachedKinematicsProfile = store.getState().machineProfile;
let attachedIniProfile = store.getState().machineProfile;
store.subscribe((state) => {
if (state.machineProfile !== attachedIniProfile) {
attachedIniProfile = state.machineProfile;
attachProfileIniConfig(store, state.profile).catch(() => {});
}
if (state.machineProfile !== attachedKinematicsProfile) {
attachedKinematicsProfile = state.machineProfile;
attachDefaultKinematicsRuntime(store, state.profile.kinematicsModuleId || state.machineProfile).catch(() => {});
}
});
window.webRtcp5AxisSimulation = { window.webRtcp5AxisSimulation = {
getState: store.getState, getState: store.getState,
dispatch: store.dispatch, dispatch: store.dispatch,
refreshKinematicsFrame: store.refreshKinematicsFrame,
saveSession: store.saveSession,
restoreSession: store.restoreSession,
stageMachineFiles: store.stageMachineFiles,
runFullBoundaryAudit: store.runFullBoundaryAudit,
getRegions: shell.getRegions, getRegions: shell.getRegions,
iniConfigReady,
kinematicsRuntimeReady,
interpreterRuntimeReady,
}; };
store.dispatch({ type: "BOOT_READY" }); store.dispatch({ type: "BOOT_READY" });
async function attachProfileIniConfig(store, profile) {
try {
const iniConfig = await loadLinuxCncIniConfig(profile);
store.dispatch({ type: "ATTACH_INI_CONFIG", profileId: profile.id, iniConfig });
return store.getState().iniConfigReadiness;
} catch (error) {
store.dispatch({ type: "INI_CONFIG_FAILED", path: profile.iniPath, error: error.message });
return store.getState().iniConfigReadiness;
}
}
async function attachDefaultKinematicsRuntime(store, moduleId = "xyzac-trt") {
const sdkModuleUrls = [
new URL("../../../wasm-port/runtime/sdk/src/linuxcnc-kinematics.js", import.meta.url).href,
new URL("../wasm-port/runtime/sdk/src/linuxcnc-kinematics.js", import.meta.url).href,
];
const errors = [];
for (const sdkModuleUrl of sdkModuleUrls) {
if (typeof Worker === "function") {
try {
const runtime = await createLinuxCncKinematicsWorkerRuntime({ moduleId, sdkModuleUrl });
store.dispatch({ type: "ATTACH_KINEMATICS_RUNTIME", runtime });
await store.refreshKinematicsFrame({ operatorMessage: `LinuxCNC kinematics ${runtime.moduleId} worker ready` });
return runtime.readiness();
} catch (error) {
errors.push(`${sdkModuleUrl} worker: ${error.message}`);
}
}
try {
const runtime = await createLinuxCncKinematicsRuntime({ moduleId, sdkModuleUrl });
store.dispatch({ type: "ATTACH_KINEMATICS_RUNTIME", runtime });
return runtime.readiness();
} catch (error) {
errors.push(`${sdkModuleUrl}: ${error.message}`);
}
}
try {
const runtime = await createLinuxCncKinematicsRuntime({ moduleId });
store.dispatch({ type: "ATTACH_KINEMATICS_RUNTIME", runtime });
return runtime.readiness();
} catch (error) {
errors.push(`default runtime: ${error.message}`);
store.dispatch({
type: "SET_FRAME_SOURCE",
sourceMode: "fixture-ui-only",
operatorMessage: `LinuxCNC kinematics runtime unavailable: ${errors.join(" | ")}`,
});
return {
apiName: "web-rtcp-5axis-linuxcnc-kinematics-runtime-readiness",
moduleId,
loaded: false,
sourceMode: "fixture-ui-only",
error: errors.join(" | "),
};
}
}
async function attachDefaultInterpreterRuntime(store) {
const sdkModuleUrls = [
new URL("../../../wasm-port/runtime/sdk/src/linuxcnc-interp.js", import.meta.url).href,
new URL("../wasm-port/runtime/sdk/src/linuxcnc-interp.js", import.meta.url).href,
];
const errors = [];
for (const sdkModuleUrl of sdkModuleUrls) {
if (typeof Worker === "function") {
try {
const runtime = await createLinuxCncInterpreterWorkerRuntime({ sdkModuleUrl });
store.dispatch({ type: "ATTACH_INTERPRETER_RUNTIME", runtime });
return runtime.readiness();
} catch (error) {
errors.push(`${sdkModuleUrl} worker: ${error.message}`);
}
}
try {
const runtime = await createLinuxCncInterpreterRuntime({ sdkModuleUrl });
store.dispatch({ type: "ATTACH_INTERPRETER_RUNTIME", runtime });
return runtime.readiness();
} catch (error) {
errors.push(`${sdkModuleUrl}: ${error.message}`);
}
}
try {
const runtime = await createLinuxCncInterpreterRuntime();
store.dispatch({ type: "ATTACH_INTERPRETER_RUNTIME", runtime });
return runtime.readiness();
} catch (error) {
errors.push(`default runtime: ${error.message}`);
return {
apiName: "web-rtcp-5axis-linuxcnc-interpreter-runtime-readiness",
loaded: false,
sourceMode: "fixture-line-playback",
error: errors.join(" | "),
};
}
}

View File

@@ -0,0 +1,12 @@
import { xyzacTrtProfile } from "./xyzac-trt.js";
import { xyzbcTrtProfile } from "./xyzbc-trt.js";
export const fiveAxisProfiles = [xyzacTrtProfile, xyzbcTrtProfile];
export function getFiveAxisProfile(profileId = "xyzac-trt") {
const profile = fiveAxisProfiles.find((entry) => entry.id === profileId);
if (!profile) {
throw new Error(`unknown five-axis profile: ${profileId}`);
}
return profile;
}

View File

@@ -71,6 +71,62 @@ export const linuxCncSourceReferenceMap = [
boundary: "linuxcnc_program_reference", boundary: "linuxcnc_program_reference",
usage: "representative XYZAC switchkins demonstration program", usage: "representative XYZAC switchkins demonstration program",
}, },
{
id: "xyzbc-trt-ini",
profileId: "xyzbc-trt",
kind: "ini",
path: "configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzbc-trt.ini",
boundary: "linuxcnc_config_reference",
usage: "machine profile, KINS, TRAJ coordinates, HAL and remap declarations",
},
{
id: "xyzbc-trt-pyvcp",
profileId: "xyzbc-trt",
kind: "pyvcp_xml",
path: "configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzbc-trt.xml",
boundary: "ui_hal_binding_reference",
usage: "SWITCHKINS labels and operator buttons",
},
{
id: "xyzbc-trt-table",
profileId: "xyzbc-trt",
kind: "tool_table",
path: "configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzbc-trt.tbl",
boundary: "linuxcnc_config_reference",
usage: "tool table source for future LinuxCNC-backed runtime session",
},
{
id: "xyzbc-trt-kins",
profileId: "xyzbc-trt",
kind: "kinematics_source",
path: "src/emc/kinematics/xyzbc-trt-kins.c",
boundary: "linuxcnc_source_required",
usage: "final source-derived XYZBC kinematics implementation source",
},
{
id: "trtfuncs-xyzbc",
profileId: "xyzbc-trt",
kind: "kinematics_source",
path: "src/emc/kinematics/trtfuncs.c",
boundary: "linuxcnc_source_required",
usage: "shared table-rotary-tilting kinematics functions",
},
{
id: "switchkins-source-xyzbc",
profileId: "xyzbc-trt",
kind: "kinematics_source",
path: "src/emc/kinematics/switchkins.c",
boundary: "linuxcnc_source_required",
usage: "switchable kinematics behavior backing M428/M429/M430",
},
{
id: "xyzbc-switchkins-demo",
profileId: "xyzbc-trt",
kind: "gcode_demo",
path: "configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/xyzbc_switchkins.ngc",
boundary: "linuxcnc_program_reference",
usage: "representative XYZBC switchkins demonstration program",
},
]; ];
export function getSourceReferencesForProfile(profileId) { export function getSourceReferencesForProfile(profileId) {

View File

@@ -0,0 +1,136 @@
import { xyzacTrtProfile } from "./xyzac-trt.js";
import { getSourceReferencesForProfile } from "./source-reference-map.js";
import { xyzacTrtPyvcpPanelSchema } from "../panel-schema/xyzac-trt-pyvcp.js";
const sourceReferenceObjects = getSourceReferencesForProfile("xyzbc-trt");
export const xyzbcTrtProfile = {
...xyzacTrtProfile,
id: "xyzbc-trt",
title: "XYZBC table rotary tilting",
iniPath: "configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzbc-trt.ini",
pyvcpXmlPath: "configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzbc-trt.xml",
generatedHalPath: null,
toolTablePath: "configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzbc-trt.tbl",
machineName: "sim-xyzbc-trt-kins (switchkins)",
display: {
...xyzacTrtProfile.display,
geometry: "XYZB",
openFile: "./demos/xyzbc_switchkins.ngc",
pyvcp: "./xyzbc-trt.xml",
},
rs274ngc: {
...xyzacTrtProfile.rs274ngc,
parameterFile: "xyzbc.var",
},
coordinates: ["X", "Y", "Z", "B", "C"],
joints: ["joint.0", "joint.1", "joint.2", "joint.3", "joint.4"],
kinematics: "xyzbc-trt-kins",
kinematicsModuleId: "xyzbc-trt",
kinematicsParameters: {
...xyzacTrtProfile.kinematicsParameters,
switchkinsTypes: [
{ value: 0, label: "identity", mdiCommand: "M429", webKinsType: "identity" },
{ value: 1, label: "XYZBC TCP", mdiCommand: "M428", webKinsType: "tcp-xyzbc" },
{ value: 2, label: "USERK", mdiCommand: "M430", webKinsType: "userk" },
],
},
hal: {
...xyzacTrtProfile.hal,
halcmd: {
...xyzacTrtProfile.hal.halcmd,
loadusr: ["xyzbc-trt-gui"],
feedbackNets: [
{ signal: "table-x", source: "joint.0.pos-fb", target: "xyzbc-trt-gui.table-x" },
{ signal: "saddle-y", source: "joint.1.pos-fb", target: "xyzbc-trt-gui.saddle-y" },
{ signal: "spindle-z", source: "joint.2.pos-fb", target: "xyzbc-trt-gui.spindle-z" },
{ signal: "tilt-b", source: "joint.3.pos-fb", target: "xyzbc-trt-gui.tilt-b" },
{ signal: "rotate-c", source: "joint.4.pos-fb", target: "xyzbc-trt-gui.rotate-c" },
],
offsetNets: [
{ signal: "tool-offset", source: "motion.tooloffset.z", target: "xyzbc-trt-kins.tool-offset" },
{ signal: "tool-offset", source: "xyzbc-trt-kins.tool-offset", target: "xyzbc-trt-gui.tool-offset" },
{ signal: "x-offset", source: "xyzbc-trt-kins.x-offset", target: "xyzbc-trt-gui.x-offset" },
{ signal: "z-offset", source: "xyzbc-trt-kins.z-offset", target: "xyzbc-trt-gui.z-offset" },
],
initialSets: [
{ pin: "x-offset", value: -20 },
{ pin: "z-offset", value: -15 },
{ pin: "xyzbc-trt-kins.x-rot-point", value: 0 },
{ pin: "xyzbc-trt-kins.y-rot-point", value: 0 },
{ pin: "xyzbc-trt-kins.z-rot-point", value: 0 },
{ pin: "xyzbc-trt-kins.conventional-directions", value: 0 },
],
},
},
traj: {
...xyzacTrtProfile.traj,
coordinates: "XYZBC",
},
axisLimits: {
X: xyzacTrtProfile.axisLimits.X,
Y: xyzacTrtProfile.axisLimits.Y,
Z: xyzacTrtProfile.axisLimits.Z,
B: { min: -36000, max: 36000, maxVelocity: 30, maxAcceleration: 300 },
C: { min: -36000, max: 36000, maxVelocity: 30, maxAcceleration: 300 },
},
jointConfig: [
xyzacTrtProfile.jointConfig[0],
xyzacTrtProfile.jointConfig[1],
xyzacTrtProfile.jointConfig[2],
{ id: 3, axis: "B", type: "ANGULAR", home: 0, min: -100, max: 50, maxVelocity: 30, maxAcceleration: 300 },
{ id: 4, axis: "C", type: "ANGULAR", home: 0, min: -36000, max: 36000, maxVelocity: 30, maxAcceleration: 300 },
],
halPins: [
"motion.switchkins-type",
"motion.analog-out-03",
"motion.tooloffset.z",
"xyzbc-trt-kins.tool-offset",
"xyzbc-trt-kins.x-offset",
"xyzbc-trt-kins.z-offset",
"xyzbc-trt-kins.x-rot-point",
"xyzbc-trt-kins.y-rot-point",
"xyzbc-trt-kins.z-rot-point",
"xyzbc-trt-kins.conventional-directions",
"halui.mdi-command-00",
"halui.mdi-command-01",
"halui.mdi-command-02",
],
offsets: {
x: -20,
z: -15,
xRotPoint: 0,
yRotPoint: 0,
zRotPoint: 0,
conventionalDirections: 0,
},
sourceReferences: sourceReferenceObjects.map((reference) => reference.path),
sourceReferenceObjects,
panelSchema: {
...xyzacTrtPyvcpPanelSchema,
id: "xyzbc-trt-switchkins-pyvcp",
profileId: "xyzbc-trt",
sourceXmlPath: "configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzbc-trt.xml",
groups: xyzacTrtPyvcpPanelSchema.groups.map((group) => ({
...group,
controls: group.controls.map((control) => (
control.id === "kinstype-legends"
? {
...control,
legends: ["0:IDENTITY", "1: XYZBC ", "2: USERK "],
}
: control.id === "type1-button"
? {
...control,
text: "TCP:XYZBC",
webAction: { type: "SET_KINS_TYPE", kinsType: "tcp-xyzbc" },
}
: control
)),
})),
},
samplePrograms: [
"configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/xyzbc_switchkins.ngc",
"linuxcnc/nc_files/3D_Chips.ngc",
],
};

View File

@@ -0,0 +1,166 @@
const LINEAR_AXES = ["x", "y", "z", "u", "v", "w"];
const ANGULAR_AXES = ["a", "b", "c"];
const ALL_AXES = [...LINEAR_AXES, ...ANGULAR_AXES];
export function buildProgramExecutionTiming({
motion = [],
profile = null,
feedOverride = 100,
rapidOverride = 100,
defaultFeedRate = 100,
} = {}) {
const limits = buildVelocityLimits(profile);
const segments = [];
let previousAxes = null;
let elapsedSeconds = 0;
let feedRate = Number(defaultFeedRate) > 0 ? Number(defaultFeedRate) : 100;
for (let index = 0; index < motion.length; index += 1) {
const event = motion[index];
const axes = normalizeAxes(event.axes, previousAxes);
if (Number.isFinite(event.feedRate) && event.feedRate > 0) {
feedRate = event.feedRate;
}
const segment = buildTimingSegment({
event,
index,
axes,
previousAxes: previousAxes || axes,
limits,
feedRate,
feedOverride,
rapidOverride,
elapsedSeconds,
});
elapsedSeconds += segment.durationSeconds;
segments.push({
...segment,
elapsedSeconds,
});
previousAxes = axes;
}
const feedSeconds = segments
.filter((segment) => segment.motionClass === "feed")
.reduce((total, segment) => total + segment.durationSeconds, 0);
const rapidSeconds = segments
.filter((segment) => segment.motionClass === "rapid")
.reduce((total, segment) => total + segment.durationSeconds, 0);
return {
apiName: "web-rtcp-5axis-program-execution-timing",
semanticBoundary: "linuxcnc_canonical_motion_timing_estimate_not_planner_queue",
sourceBasis: "LinuxCNC canonical motion events plus INI/profile velocity limits and feed overrides",
totalSeconds: elapsedSeconds,
totalMinutes: elapsedSeconds / 60,
feedSeconds,
rapidSeconds,
motionCount: segments.length,
segments,
limits,
};
}
export function timingAtMotionIndex(timing, motionIndex = 0) {
const segment = timing?.segments?.[motionIndex] || null;
return {
elapsedSeconds: segment?.elapsedSeconds || 0,
remainingSeconds: Math.max((timing?.totalSeconds || 0) - (segment?.elapsedSeconds || 0), 0),
currentVelocity: segment?.velocityMmPerMin || 0,
segmentDurationSeconds: segment?.durationSeconds || 0,
segmentDistanceMm: segment?.linearDistanceMm || 0,
};
}
function buildTimingSegment({
event,
index,
axes,
previousAxes,
limits,
feedRate,
feedOverride,
rapidOverride,
elapsedSeconds,
}) {
const deltas = Object.fromEntries(ALL_AXES.map((axis) => [axis, axes[axis] - previousAxes[axis]]));
const linearDistanceMm = vectorLength(LINEAR_AXES.map((axis) => deltas[axis]));
const angularDistanceDeg = vectorLength(ANGULAR_AXES.map((axis) => deltas[axis]));
const motionClass = event.type === "STRAIGHT_TRAVERSE" ? "rapid" : "feed";
const requestedLinearVelocity = motionClass === "rapid"
? limits.maxLinearVelocityMmPerMin * percent(rapidOverride)
: Math.max(feedRate, 0) * percent(feedOverride);
const cappedLinearVelocity = Math.min(
requestedLinearVelocity || limits.defaultLinearVelocityMmPerMin,
limits.maxLinearVelocityMmPerMin,
);
const linearSeconds = linearDistanceMm > 0
? linearDistanceMm / Math.max(cappedLinearVelocity / 60, 0.000001)
: 0;
const angularVelocityDegPerMin = motionClass === "rapid"
? limits.maxAngularVelocityDegPerMin * percent(rapidOverride)
: Math.min(Math.max(feedRate, 0) * percent(feedOverride), limits.maxAngularVelocityDegPerMin);
const angularSeconds = angularDistanceDeg > 0
? angularDistanceDeg / Math.max(angularVelocityDegPerMin / 60, 0.000001)
: 0;
const durationSeconds = Math.max(linearSeconds, angularSeconds);
return {
index,
line: event.line,
type: event.type,
motionClass,
linearDistanceMm,
angularDistanceDeg,
feedRate,
requestedVelocityMmPerMin: requestedLinearVelocity,
velocityMmPerMin: cappedLinearVelocity,
angularVelocityDegPerMin,
durationSeconds,
startSeconds: elapsedSeconds,
axes,
deltas,
};
}
function buildVelocityLimits(profile) {
const traj = profile?.traj || {};
const axisLimits = profile?.axisLimits || {};
const maxLinearVelocity = firstFinite(
Number(traj.maxLinearVelocity) * 60,
...LINEAR_AXES.map((axis) => Number(axisLimits[axis.toUpperCase()]?.maxVelocity) * 60),
2100,
);
const defaultLinearVelocity = firstFinite(Number(traj.defaultLinearVelocity) * 60, maxLinearVelocity, 1200);
const maxAngularVelocity = firstFinite(
...ANGULAR_AXES.map((axis) => Number(axisLimits[axis.toUpperCase()]?.maxVelocity) * 60),
maxLinearVelocity,
);
return {
maxLinearVelocityMmPerMin: maxLinearVelocity,
defaultLinearVelocityMmPerMin: defaultLinearVelocity,
maxAngularVelocityDegPerMin: maxAngularVelocity,
};
}
function normalizeAxes(axes = {}, fallback = null) {
return Object.fromEntries(ALL_AXES.map((axis) => [
axis,
Number.isFinite(Number(axes[axis]))
? Number(axes[axis])
: Number(fallback?.[axis] || 0),
]));
}
function vectorLength(values) {
return Math.sqrt(values.reduce((total, value) => total + value * value, 0));
}
function percent(value) {
const number = Number(value);
return Number.isFinite(number) ? Math.max(number, 0) / 100 : 1;
}
function firstFinite(...values) {
return values.find((value) => Number.isFinite(value) && value > 0) || 1;
}

View File

@@ -0,0 +1,271 @@
export const FIVE_AXIS_SESSION_FORMAT = "web-rtcp-5axis-session-snapshot";
export const FIVE_AXIS_SESSION_VERSION = 1;
export const DEFAULT_SESSION_ID = "gmoccapy-web-session";
export const DEFAULT_SESSION_FILENAME = "web-rtcp-5axis-session.json";
export function createFiveAxisSessionPayload(state) {
return validateFiveAxisSessionPayload({
apiName: "web-rtcp-5axis-session-payload",
payloadVersion: 1,
machineProfile: state.machineProfile,
sessionName: state.sessionName,
sourceMode: state.sourceMode,
machine: state.machine,
runState: state.runState,
activeProgram: state.activeProgram,
programSource: state.programSource,
programStartLine: state.programStartLine,
activeLine: state.activeLine,
lineCount: state.lineCount,
fileSizeBytes: state.fileSizeBytes,
programLines: state.programLines,
axisPose: state.axisPose,
jointPose: state.jointPose,
tcpPose: state.tcpPose,
toolAxisVector: state.toolAxisVector,
rtcpState: state.rtcpState,
kinsType: state.kinsType,
feed: state.feed,
spindle: state.spindle,
coolant: state.coolant,
preview: state.preview,
toolPreview: state.toolPreview,
programExecutionSourceMode: state.programExecutionSourceMode,
programExecutionTiming: state.programExecutionTiming,
programElapsedSeconds: state.programElapsedSeconds,
programRemainingSeconds: state.programRemainingSeconds,
programRuntimeFeedback: state.programRuntimeFeedback,
programExecution: state.programExecution
? {
apiName: state.programExecution.apiName,
sourceMode: state.programExecution.sourceMode,
semanticBoundary: state.programExecution.semanticBoundary,
motion: state.programExecution.motion,
plannerTiming: state.programExecution.plannerTiming || null,
summary: state.programExecution.summary,
}
: null,
kinematicsRuntimeReadiness: state.kinematicsRuntimeReadiness,
interpreterRuntimeReadiness: state.interpreterRuntimeReadiness,
linuxCncBoundaryReadiness: state.linuxCncBoundaryReadiness,
});
}
export function createFiveAxisSessionSnapshot(sessionId, payload, options = {}) {
validateSessionId(sessionId);
validateFiveAxisSessionPayload(payload);
return {
format: FIVE_AXIS_SESSION_FORMAT,
version: FIVE_AXIS_SESSION_VERSION,
sessionId,
createdAt: options.createdAt || new Date().toISOString(),
metadata: {
source: "web-rtcp-5axis-sim-plan",
profile: payload.machineProfile,
program: payload.activeProgram,
...(options.metadata || {}),
},
payload,
};
}
export function validateFiveAxisSessionSnapshot(snapshot, sessionId) {
assertPlainObject(snapshot, "five-axis session snapshot");
if (snapshot.format !== FIVE_AXIS_SESSION_FORMAT) {
throw new Error(`Unsupported five-axis session snapshot format: ${snapshot.format}`);
}
if (snapshot.version !== FIVE_AXIS_SESSION_VERSION) {
throw new Error(`Unsupported five-axis session snapshot version: ${snapshot.version}`);
}
if (snapshot.sessionId !== sessionId) {
throw new Error(`Five-axis session snapshot id mismatch: ${snapshot.sessionId}`);
}
assertPlainObject(snapshot.metadata, "five-axis session snapshot metadata");
validateFiveAxisSessionPayload(snapshot.payload);
return snapshot;
}
export async function saveFiveAxisSessionSnapshot(sessionId, payload, options = {}) {
const snapshot = createFiveAxisSessionSnapshot(sessionId, payload, options);
const path = sessionSnapshotPath(sessionId, options.filename);
await saveTextFile(path, `${JSON.stringify(snapshot, null, 2)}\n`, options.storage);
return { snapshot, path };
}
export async function loadFiveAxisSessionSnapshot(sessionId, options = {}) {
const path = sessionSnapshotPath(sessionId, options.filename);
const text = await loadTextFile(path, options.storage);
let snapshot;
try {
snapshot = JSON.parse(text);
} catch (error) {
throw new Error(`Invalid five-axis session snapshot JSON: ${error.message}`);
}
return { snapshot: validateFiveAxisSessionSnapshot(snapshot, sessionId), path };
}
export function restoreFiveAxisSessionState(snapshot) {
const payload = validateFiveAxisSessionPayload(snapshot.payload);
return {
machineProfile: payload.machineProfile,
sessionName: payload.sessionName,
machine: payload.machine,
runState: payload.runState,
activeProgram: payload.activeProgram,
programSource: payload.programSource,
programStartLine: payload.programStartLine,
activeLine: payload.activeLine,
lineCount: payload.lineCount,
fileSizeBytes: payload.fileSizeBytes,
programLines: payload.programLines,
axisPose: payload.axisPose,
rtcpState: payload.rtcpState,
kinsType: payload.kinsType,
feed: payload.feed,
spindle: payload.spindle,
coolant: payload.coolant,
preview: payload.preview,
toolPreview: payload.toolPreview,
programExecutionSourceMode: payload.programExecutionSourceMode,
programExecutionTiming: payload.programExecutionTiming,
programElapsedSeconds: payload.programElapsedSeconds,
programRemainingSeconds: payload.programRemainingSeconds,
programRuntimeFeedback: payload.programRuntimeFeedback,
programExecution: payload.programExecution,
};
}
export function createMemorySessionStorage(seed = {}) {
const files = new Map(Object.entries(seed));
return {
files,
async getDirectory() {
return createDirectoryHandle(files, []);
},
};
}
function validateFiveAxisSessionPayload(payload) {
assertPlainObject(payload, "five-axis session payload");
if (payload.apiName !== "web-rtcp-5axis-session-payload") {
throw new Error(`Unsupported five-axis session payload API: ${payload.apiName}`);
}
if (payload.payloadVersion !== 1) {
throw new Error(`Unsupported five-axis session payload version: ${payload.payloadVersion}`);
}
if (!["xyzac-trt", "xyzbc-trt"].includes(payload.machineProfile)) {
throw new Error(`Unsupported five-axis machine profile: ${payload.machineProfile}`);
}
if (!Array.isArray(payload.programLines)) {
throw new Error("five-axis session programLines must be an array.");
}
if (payload.programExecution !== null) {
assertPlainObject(payload.programExecution, "five-axis session programExecution");
if (!Array.isArray(payload.programExecution.motion)) {
throw new Error("five-axis session programExecution.motion must be an array.");
}
assertPlainObject(payload.programExecution.summary, "five-axis session programExecution.summary");
}
for (const key of ["machine", "axisPose", "feed", "spindle", "coolant", "preview", "toolPreview"]) {
assertPlainObject(payload[key], `five-axis session ${key}`);
}
return payload;
}
function sessionSnapshotPath(sessionId, filename = DEFAULT_SESSION_FILENAME) {
validateSessionId(sessionId);
validateFilename(filename);
return `web-rtcp-5axis-sim-plan/sessions/${sessionId}/${filename}`;
}
async function saveTextFile(path, text, storage = globalThis.navigator?.storage) {
const root = await getStorageRoot(storage);
const dir = await ensureParentDir(root, path);
const filename = splitPath(path).at(-1);
const fileHandle = await dir.getFileHandle(filename, { create: true });
const writable = await fileHandle.createWritable();
await writable.write(text);
await writable.close();
}
async function loadTextFile(path, storage = globalThis.navigator?.storage) {
const root = await getStorageRoot(storage);
const parts = splitPath(path);
let current = root;
for (const part of parts.slice(0, -1)) {
current = await current.getDirectoryHandle(part);
}
const fileHandle = await current.getFileHandle(parts.at(-1));
const file = await fileHandle.getFile();
return file.text();
}
async function getStorageRoot(storage) {
if (!storage?.getDirectory) {
throw new Error("OPFS is not available in this browser.");
}
return storage.getDirectory();
}
async function ensureParentDir(root, path) {
let current = root;
for (const part of splitPath(path).slice(0, -1)) {
current = await current.getDirectoryHandle(part, { create: true });
}
return current;
}
function createDirectoryHandle(files, prefix) {
return {
async getDirectoryHandle(name) {
return createDirectoryHandle(files, [...prefix, name]);
},
async getFileHandle(name) {
const path = [...prefix, name].join("/");
return {
async createWritable() {
let content = "";
return {
async write(text) {
content += String(text);
},
async close() {
files.set(path, content);
},
};
},
async getFile() {
if (!files.has(path)) throw new Error(`Missing memory session file: ${path}`);
return { async text() { return files.get(path); } };
},
};
},
};
}
function splitPath(path) {
const value = String(path || "").replaceAll("\\", "/");
const parts = value.split("/").filter(Boolean);
if (parts.length === 0 || parts.some((part) => part === "." || part === "..")) {
throw new Error(`Invalid session path: ${path}`);
}
return parts;
}
function validateSessionId(sessionId) {
if (!/^[a-zA-Z0-9._-]+$/.test(String(sessionId || ""))) {
throw new Error(`Invalid five-axis session id: ${sessionId}`);
}
}
function validateFilename(filename) {
if (!/^[a-zA-Z0-9._-]+\.json$/.test(String(filename || ""))) {
throw new Error(`Invalid five-axis session filename: ${filename}`);
}
}
function assertPlainObject(value, label) {
if (value === null || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`${label} must be a plain object.`);
}
}

View File

@@ -0,0 +1,106 @@
const MACHINE_FILE_FLAGS = [
"fiveaxis_ini_open=1",
"fiveaxis_remaps_ready=1",
"fiveaxis_file_reached_exit=1",
];
export function createFullLinuxCncExecutionBoundary(state = {}) {
const adapter = state.linuxCncBoundaryAdapter || {};
const frame = state.rtcpFrame || {};
const programExecution = state.programExecution || null;
const machineFileExecution = state.machineFileExecution || null;
const machineFileText = String(machineFileExecution?.resultText || "");
const kinematicsReady = Boolean(
adapter.linuxCncKinematicsReady ||
frame.readiness?.linuxCncKinematicsReady,
);
const interpreterReady = Boolean(
adapter.linuxCncInterpreterReady ||
state.interpreterRuntimeReadiness?.loaded,
);
const canonicalProgramReady = Boolean(
programExecution?.sourceMode === "linuxcnc-interpreter-wasm" &&
programExecution?.summary?.motionEventCount > 0,
);
const machineFileStagingReady = state.machineFileStaging?.status === "staged"
&& state.machineFileStaging?.fileCount > 0;
const machineFileRemapReady = Boolean(
machineFileExecution?.sourceMode === "linuxcnc-machine-file-remap-wasm" &&
machineFileExecution?.summary?.machineFileExecutionReady === true &&
MACHINE_FILE_FLAGS.every((flag) => machineFileText.includes(flag)),
);
const plannerRuntimeReady = Boolean(
programExecution?.summary?.plannerRuntimeReady === true &&
programExecution?.plannerTiming?.plannerRuntimeReady === true,
);
const halSwitchkinsEvidenceReady = machineFileText.includes("fiveaxis_hal_switchkins: rc=0 found=1");
const satisfied = [
kinematicsReady ? "linuxcnc-kinematics-wasm" : null,
interpreterReady ? "linuxcnc-interpreter-wasm" : null,
canonicalProgramReady ? "canonical-motion-events" : null,
machineFileStagingReady ? "machine-file-staging" : null,
machineFileRemapReady ? "fiveaxis-remap-machine-file-run" : null,
plannerRuntimeReady ? "linuxcnc-tp-queue-runtime-timing" : null,
halSwitchkinsEvidenceReady ? "switchkins-hal-bridge-evidence" : null,
].filter(Boolean);
const missing = [];
if (!kinematicsReady) missing.push("linuxcnc kinematics WASM frame");
if (!interpreterReady) missing.push("linuxcnc interpreter WASM runtime");
if (!canonicalProgramReady) missing.push("linuxcnc canonical motion execution");
if (!machineFileStagingReady) missing.push("LinuxCNC machine-file staging");
if (!machineFileRemapReady) missing.push("machine-file backed five-axis remap run");
if (!plannerRuntimeReady) missing.push("LinuxCNC trajectory planner queue timing runtime");
if (!halSwitchkinsEvidenceReady) missing.push("switchkins HAL bridge evidence");
const blockers = [
"native LinuxCNC task/NML process is not ported",
"native realtime HAL thread synchronization is not ported",
"external user-M process and full tool DB process are not promoted",
];
if (!plannerRuntimeReady) {
blockers.push("LinuxCNC trajectory planner queue is not promoted as browser runtime");
}
return {
apiName: "web-rtcp-5axis-full-linuxcnc-execution-boundary",
profileId: state.machineProfile || adapter.profileId || "unknown",
phase: machineFileRemapReady
? "partial-linuxcnc-remap-boundary"
: canonicalProgramReady
? "canonical-interpreter-boundary"
: "blocked",
semanticBoundary: machineFileRemapReady
? "linuxcnc_machine_file_remap_ready_planner_task_hal_blocked"
: canonicalProgramReady
? "linuxcnc_interpreter_canonical_ready_planner_task_hal_blocked"
: "linuxcnc_full_execution_boundary_blocked",
sourceMode: machineFileRemapReady
? "linuxcnc-machine-file-remap-wasm"
: programExecution?.sourceMode || state.programExecutionSourceMode || "fixture-line-playback",
readyForUiSimulation: kinematicsReady && interpreterReady && canonicalProgramReady,
machineFileBackedRemapReady: machineFileRemapReady,
remapRuntimeReady: machineFileRemapReady,
halSwitchkinsEvidenceReady,
plannerRuntimeReady,
nativeTaskReady: false,
nativeHalSyncReady: false,
fullLinuxCncProgramExecutionReady: false,
promotionAllowed: false,
satisfied,
missing,
blockers,
evidence: {
kinematics: kinematicsReady ? frame.semanticBoundary || adapter.semanticBoundary : null,
interpreter: interpreterReady ? state.interpreterRuntimeReadiness?.semanticBoundary || adapter.semanticBoundary : null,
canonicalMotionEvents: programExecution?.summary?.motionEventCount || 0,
canonicalEventCount: programExecution?.summary?.canonicalEventCount || 0,
plannerTiming: plannerRuntimeReady ? programExecution.plannerTiming?.semanticBoundary : null,
machineFileFlags: MACHINE_FILE_FLAGS.filter((flag) => machineFileText.includes(flag)),
machineFileExecutionReady: machineFileExecution?.summary?.machineFileExecutionReady === true,
stagedFileCount: state.machineFileStaging?.fileCount || 0,
},
};
}

View File

@@ -4,7 +4,7 @@ import { createProfileSourceReferenceSummary } from "../profiles/source-referenc
export function createLinuxCncBoundaryAdapter({ export function createLinuxCncBoundaryAdapter({
profile = xyzacTrtProfile, profile = xyzacTrtProfile,
panelSchema = xyzacTrtPyvcpPanelSchema, panelSchema = profile.panelSchema || xyzacTrtPyvcpPanelSchema,
runtime = null, runtime = null,
} = {}) { } = {}) {
const sourceSummary = createProfileSourceReferenceSummary(profile.id); const sourceSummary = createProfileSourceReferenceSummary(profile.id);
@@ -14,6 +14,8 @@ export function createLinuxCncBoundaryAdapter({
const runtimeReady = kinematicsRuntimeReady && interpreterRuntimeReady; const runtimeReady = kinematicsRuntimeReady && interpreterRuntimeReady;
const linuxCncKinematicsReady = kinematicsRuntimeReady const linuxCncKinematicsReady = kinematicsRuntimeReady
&& runtime.kinematicsWasm.sourceMode === "source-derived-kinematics-wasm"; && runtime.kinematicsWasm.sourceMode === "source-derived-kinematics-wasm";
const linuxCncInterpreterReady = interpreterRuntimeReady
&& runtime.interpreterWasm.sourceMode === "linuxcnc-interpreter-wasm";
return { return {
apiName: "web-rtcp-5axis-linuxcnc-boundary-adapter", apiName: "web-rtcp-5axis-linuxcnc-boundary-adapter",
@@ -24,13 +26,14 @@ export function createLinuxCncBoundaryAdapter({
runtimeReady, runtimeReady,
kinematicsRuntimeReady, kinematicsRuntimeReady,
interpreterRuntimeReady, interpreterRuntimeReady,
linuxCncInterpreterReady,
profileSummary: createProfileSummary(profile), profileSummary: createProfileSummary(profile),
linuxCncKinematicsReady, linuxCncKinematicsReady,
promotionAllowed: linuxCncKinematicsReady, promotionAllowed: linuxCncKinematicsReady,
fullLinuxCncProgramExecutionReady: false, fullLinuxCncProgramExecutionReady: false,
semanticBoundary: linuxCncKinematicsReady semanticBoundary: linuxCncKinematicsReady
? interpreterRuntimeReady ? linuxCncInterpreterReady
? "linuxcnc_runtime_supplied_but_interpreter_or_remap_not_promoted" ? "linuxcnc_kinematics_and_interpreter_wasm_connected_remap_planner_not_promoted"
: "linuxcnc_kinematics_wasm_runtime_connected" : "linuxcnc_kinematics_wasm_runtime_connected"
: "adapter_entrypoint_only_runtime_not_connected", : "adapter_entrypoint_only_runtime_not_connected",
adapterPoints: { adapterPoints: {
@@ -74,6 +77,7 @@ export function createLinuxCncBoundaryReadiness(adapter = createLinuxCncBoundary
&& !missing.includes("PyVCP/HAL panel schema"), && !missing.includes("PyVCP/HAL panel schema"),
missing, missing,
linuxCncKinematicsReady: adapter.linuxCncKinematicsReady, linuxCncKinematicsReady: adapter.linuxCncKinematicsReady,
linuxCncInterpreterReady: adapter.linuxCncInterpreterReady,
promotionAllowed: adapter.promotionAllowed, promotionAllowed: adapter.promotionAllowed,
fullLinuxCncProgramExecutionReady: adapter.fullLinuxCncProgramExecutionReady, fullLinuxCncProgramExecutionReady: adapter.fullLinuxCncProgramExecutionReady,
semanticBoundary: adapter.semanticBoundary, semanticBoundary: adapter.semanticBoundary,

View File

@@ -0,0 +1,345 @@
const AXIS_SECTION_RE = /^AXIS_([A-Z])$/;
const JOINT_SECTION_RE = /^JOINT_(\d+)$/;
export function parseLinuxCncIni(text, { path = "inline.ini", profileId = "unknown" } = {}) {
const sections = parseIniSections(text);
const kinsText = getFirstValue(sections, "KINS", "KINEMATICS") || "";
const coordinates = getFirstValue(sections, "TRAJ", "COORDINATES") || "";
const jointCount = numberOrNull(getFirstValue(sections, "KINS", "JOINTS"));
const axisLimits = parseAxisLimits(sections);
const jointConfig = parseJointConfig(sections, coordinates);
const remaps = parseRemaps(sections);
const hal = parseHal(sections);
const display = parseDisplay(sections);
const halui = {
mdiCommands: getValues(sections, "HALUI", "MDI_COMMAND"),
};
const kinematicsModuleId = inferKinematicsModuleId(kinsText);
return {
apiName: "web-rtcp-5axis-linuxcnc-ini-config",
profileId,
path,
machineName: getFirstValue(sections, "EMC", "MACHINE") || null,
kinematics: parseKinematics(kinsText),
kinematicsModuleId,
kinematicsParameters: {
sparm: parseKinematicsParameter(kinsText, "sparm"),
joints: jointCount,
switchkinsTypes: inferSwitchkinsTypes({ coordinates, halui, kinematicsModuleId }),
},
traj: {
coordinates,
linearUnits: getFirstValue(sections, "TRAJ", "LINEAR_UNITS") || null,
angularUnits: getFirstValue(sections, "TRAJ", "ANGULAR_UNITS") || null,
defaultLinearVelocity: numberOrNull(getFirstValue(sections, "TRAJ", "DEFAULT_LINEAR_VELOCITY")),
maxLinearVelocity: numberOrNull(getFirstValue(sections, "TRAJ", "MAX_LINEAR_VELOCITY")),
defaultLinearAcceleration: numberOrNull(getFirstValue(sections, "TRAJ", "DEFAULT_LINEAR_ACCELERATION")),
maxLinearAcceleration: numberOrNull(getFirstValue(sections, "TRAJ", "MAX_LINEAR_ACCELERATION")),
},
display,
rs274ngc: {
subroutinePath: getFirstValue(sections, "RS274NGC", "SUBROUTINE_PATH") || null,
halPinVars: boolFromIni(getFirstValue(sections, "RS274NGC", "HAL_PIN_VARS")),
parameterFile: getFirstValue(sections, "RS274NGC", "PARAMETER_FILE") || null,
remaps,
},
hal,
halui,
axisLimits,
jointConfig,
emcio: {
toolTable: getFirstValue(sections, "EMCIO", "TOOL_TABLE") || null,
},
validation: validateIniConfig({ coordinates, jointCount, axisLimits, jointConfig, kinsText }),
semanticBoundary: "linuxcnc_ini_file_browser_parser",
};
}
export async function loadLinuxCncIniConfig(profile, { baseUrl = import.meta.url, fetchImpl = globalThis.fetch } = {}) {
if (!profile?.iniPath) {
throw new Error("profile is missing iniPath");
}
if (typeof fetchImpl !== "function") {
throw new Error("fetch is not available for LinuxCNC INI loading");
}
const candidateUrls = [
new URL(`../../${profile.iniPath}`, baseUrl),
new URL(`../../../../wasm-port/vendor/linuxcnc/${profile.iniPath}`, baseUrl),
];
const errors = [];
let response = null;
for (const url of candidateUrls) {
try {
response = await fetchImpl(url.href);
if (response.ok) break;
errors.push(`${url.href}: HTTP ${response.status}`);
response = null;
} catch (error) {
errors.push(`${url.href}: ${error.message}`);
}
}
if (!response) {
throw new Error(`failed to load LinuxCNC INI ${profile.iniPath}: ${errors.join(" | ")}`);
}
return parseLinuxCncIni(await response.text(), {
path: profile.iniPath,
profileId: profile.id,
});
}
export function applyIniConfigToProfile(profile, iniConfig) {
if (!iniConfig) return profile;
return {
...profile,
machineName: iniConfig.machineName || profile.machineName,
kinematics: iniConfig.kinematics.name || profile.kinematics,
kinematicsModuleId: iniConfig.kinematicsModuleId || profile.kinematicsModuleId,
kinematicsParameters: {
...profile.kinematicsParameters,
...iniConfig.kinematicsParameters,
switchkinsTypes: mergeSwitchkinsTypes(
profile.kinematicsParameters?.switchkinsTypes || [],
iniConfig.kinematicsParameters.switchkinsTypes,
),
},
display: {
...profile.display,
...iniConfig.display,
},
rs274ngc: {
...profile.rs274ngc,
subroutinePath: iniConfig.rs274ngc.subroutinePath || profile.rs274ngc?.subroutinePath,
halPinVars: iniConfig.rs274ngc.halPinVars ?? profile.rs274ngc?.halPinVars,
parameterFile: iniConfig.rs274ngc.parameterFile || profile.rs274ngc?.parameterFile,
},
hal: {
...profile.hal,
halui: iniConfig.hal.halui || profile.hal?.halui,
halFiles: iniConfig.hal.halFiles.length > 0 ? iniConfig.hal.halFiles : profile.hal?.halFiles,
postguiHalFiles: iniConfig.hal.postguiHalFiles.length > 0
? iniConfig.hal.postguiHalFiles
: profile.hal?.postguiHalFiles,
halcmd: {
...profile.hal?.halcmd,
raw: iniConfig.hal.halcmd,
initialSets: iniConfig.hal.initialSets.length > 0
? iniConfig.hal.initialSets
: profile.hal?.halcmd?.initialSets,
},
},
halui: iniConfig.halui.mdiCommands.length > 0 ? iniConfig.halui : profile.halui,
traj: {
...profile.traj,
...dropNullish(iniConfig.traj),
},
axisLimits: Object.keys(iniConfig.axisLimits).length > 0 ? iniConfig.axisLimits : profile.axisLimits,
jointConfig: iniConfig.jointConfig.length > 0 ? iniConfig.jointConfig : profile.jointConfig,
linuxCncIniConfig: iniConfig,
};
}
function parseIniSections(text) {
const sections = new Map();
let current = null;
for (const rawLine of String(text).split(/\r?\n/)) {
const line = stripIniComment(rawLine).trim();
if (!line) continue;
const sectionMatch = line.match(/^\[([^\]]+)]$/);
if (sectionMatch) {
current = sectionMatch[1].trim().toUpperCase();
if (!sections.has(current)) sections.set(current, new Map());
continue;
}
if (!current) continue;
const equals = line.indexOf("=");
if (equals < 0) continue;
const key = line.slice(0, equals).trim().toUpperCase();
const value = line.slice(equals + 1).trim();
const section = sections.get(current);
if (!section.has(key)) section.set(key, []);
section.get(key).push(value);
}
return sections;
}
function stripIniComment(line) {
let quote = null;
for (let index = 0; index < line.length; index += 1) {
const char = line[index];
if ((char === "\"" || char === "'") && line[index - 1] !== "\\") {
quote = quote === char ? null : quote || char;
}
if (!quote && (char === "#" || char === ";")) {
return line.slice(0, index);
}
}
return line;
}
function getValues(sections, sectionName, key) {
return sections.get(sectionName.toUpperCase())?.get(key.toUpperCase()) || [];
}
function getFirstValue(sections, sectionName, key) {
return getValues(sections, sectionName, key)[0] ?? null;
}
function parseAxisLimits(sections) {
const result = {};
for (const [sectionName, values] of sections) {
const match = sectionName.match(AXIS_SECTION_RE);
if (!match) continue;
result[match[1]] = {
min: numberOrNull(first(values, "MIN_LIMIT")),
max: numberOrNull(first(values, "MAX_LIMIT")),
maxVelocity: numberOrNull(first(values, "MAX_VELOCITY")),
maxAcceleration: numberOrNull(first(values, "MAX_ACCELERATION")),
};
}
return result;
}
function parseJointConfig(sections, coordinates) {
const axisOrder = String(coordinates || "").split("");
return [...sections.entries()]
.map(([sectionName, values]) => {
const match = sectionName.match(JOINT_SECTION_RE);
if (!match) return null;
const id = Number(match[1]);
return {
id,
axis: axisOrder[id] || null,
type: first(values, "TYPE") || null,
home: numberOrNull(first(values, "HOME")),
min: numberOrNull(first(values, "MIN_LIMIT")),
max: numberOrNull(first(values, "MAX_LIMIT")),
maxVelocity: numberOrNull(first(values, "MAX_VELOCITY")),
maxAcceleration: numberOrNull(first(values, "MAX_ACCELERATION")),
homeSearchVelocity: numberOrNull(first(values, "HOME_SEARCH_VEL")),
homeSequence: numberOrNull(first(values, "HOME_SEQUENCE")),
};
})
.filter(Boolean)
.sort((left, right) => left.id - right.id);
}
function parseRemaps(sections) {
return getValues(sections, "RS274NGC", "REMAP").map((value) => {
const code = value.match(/\bM\d+\b/i)?.[0]?.toUpperCase() || null;
const ngc = value.match(/\bngc=([^\s]+)/i)?.[1] || null;
const modalGroup = numberOrNull(value.match(/\bmodalgroup=(\d+)/i)?.[1]);
return { raw: value, code, modalGroup, ngc };
});
}
function parseHal(sections) {
const halcmd = getValues(sections, "HAL", "HALCMD");
return {
halui: getFirstValue(sections, "HAL", "HALUI") || null,
halFiles: getValues(sections, "HAL", "HALFILE"),
postguiHalFiles: getValues(sections, "HAL", "POSTGUI_HALFILE"),
halcmd,
initialSets: halcmd
.map((line) => line.match(/^\s*(sets|setp)\s+:?([^\s]+)\s+([-+0-9.eE]+)/i))
.filter(Boolean)
.map((match) => ({ op: match[1], pin: match[2], value: Number(match[3]) })),
};
}
function parseDisplay(sections) {
const jogAxes = getFirstValue(sections, "DISPLAY", "JOG_AXES");
return dropNullish({
geometry: getFirstValue(sections, "DISPLAY", "GEOMETRY"),
display: getFirstValue(sections, "DISPLAY", "DISPLAY"),
jogAxes: jogAxes ? jogAxes.split("") : null,
pyvcp: getFirstValue(sections, "DISPLAY", "PYVCP"),
openFile: getFirstValue(sections, "DISPLAY", "OPEN_FILE"),
programPrefix: getFirstValue(sections, "DISPLAY", "PROGRAM_PREFIX"),
positionOffset: getFirstValue(sections, "DISPLAY", "POSITION_OFFSET"),
positionFeedback: getFirstValue(sections, "DISPLAY", "POSITION_FEEDBACK"),
maxFeedOverride: numberOrNull(getFirstValue(sections, "DISPLAY", "MAX_FEED_OVERRIDE")),
maxLinearVelocity: numberOrNull(getFirstValue(sections, "DISPLAY", "MAX_LINEAR_VELOCITY")),
maxAngularVelocity: numberOrNull(getFirstValue(sections, "DISPLAY", "MAX_ANGULAR_VELOCITY")),
});
}
function parseKinematics(text) {
const [name, ...parameters] = String(text || "").trim().split(/\s+/).filter(Boolean);
return {
name: name || null,
raw: text,
parameters,
};
}
function parseKinematicsParameter(text, key) {
return String(text || "").match(new RegExp(`\\b${key}=([^\\s]+)`, "i"))?.[1] || null;
}
function inferKinematicsModuleId(kinsText) {
const name = parseKinematics(kinsText).name || "";
if (name.includes("xyzbc")) return "xyzbc-trt";
if (name.includes("xyzac")) return "xyzac-trt";
return name.replace(/-kins$/, "") || null;
}
function inferSwitchkinsTypes({ coordinates, halui, kinematicsModuleId }) {
const mdiCommands = halui.mdiCommands.length > 0 ? halui.mdiCommands : ["M429", "M428", "M430"];
const tcpType = coordinates === "XYZBC" ? "tcp-xyzbc" : "tcp-xyzac";
const tcpLabel = `${coordinates || kinematicsModuleId || "TCP"} TCP`;
return mdiCommands.map((command, index) => ({
value: index === 0 ? 0 : index,
label: index === 0 ? "identity" : index === 1 ? tcpLabel : "USERK",
mdiCommand: command,
webKinsType: index === 0 ? "identity" : index === 1 ? tcpType : "userk",
}));
}
function validateIniConfig({ coordinates, jointCount, axisLimits, jointConfig, kinsText }) {
const missing = [];
if (!coordinates) missing.push("TRAJ.COORDINATES");
if (!jointCount) missing.push("KINS.JOINTS");
if (!kinsText) missing.push("KINS.KINEMATICS");
for (const axis of String(coordinates || "").split("")) {
if (!axisLimits[axis]) missing.push(`AXIS_${axis}`);
}
if (jointCount && jointConfig.length !== jointCount) {
missing.push(`JOINT_ count ${jointConfig.length}/${jointCount}`);
}
return {
ready: missing.length === 0,
missing,
axisCount: Object.keys(axisLimits).length,
jointCount: jointConfig.length,
};
}
function first(values, key) {
return values.get(key)?.[0] ?? null;
}
function numberOrNull(value) {
if (value === null || value === undefined || value === "") return null;
const number = Number(value);
return Number.isFinite(number) ? number : null;
}
function boolFromIni(value) {
if (value === null || value === undefined) return null;
return ["1", "true", "yes", "on"].includes(String(value).trim().toLowerCase());
}
function dropNullish(object) {
return Object.fromEntries(Object.entries(object).filter(([, value]) => value !== null && value !== undefined));
}
function mergeSwitchkinsTypes(profileTypes, iniTypes) {
if (!iniTypes?.length) return profileTypes;
return iniTypes.map((iniType) => ({
...iniType,
...(profileTypes.find((entry) => entry.mdiCommand === iniType.mdiCommand || entry.value === iniType.value) || {}),
...iniType,
}));
}

View File

@@ -0,0 +1,465 @@
const DEFAULT_SDK_MODULE_URL = "../../../../wasm-port/runtime/sdk/src/linuxcnc-interp.js";
const DEFAULT_TP_SDK_MODULE_URL = "../../../../wasm-port/runtime/sdk/src/linuxcnc-tp.js";
const SOURCE_MODE = "linuxcnc-interpreter-wasm";
const SEMANTIC_BOUNDARY = "linuxcnc_interpreter_wasm_canonical_events";
const PLANNER_TIMING_BOUNDARY = "linuxcnc_tp_queue_runtime_timing_from_canonical_motion";
const SWITCHKINS_REMAP_BOUNDARY = "linuxcnc_switchkins_remap_mcode_preserved_web_runtime_applied";
const FIVE_AXIS_REMAP_FLAGS = [
"fiveaxis_ini_open=1",
"fiveaxis_remaps_ready=1",
"fiveaxis_file_reached_exit=1",
];
const AXES = ["x", "y", "z", "a", "b", "c", "u", "v", "w"];
const SWITCHKINS_M_CODES = new Map([
[428, { switchkinsType: 1, requestedKinsType: "tcp" }],
[429, { switchkinsType: 0, requestedKinsType: "identity" }],
[430, { switchkinsType: 2, requestedKinsType: "userk" }],
]);
const PLANE_AXIS_MAP = {
170: ["x", "y", "z"],
180: ["x", "z", "y"],
190: ["y", "z", "x"],
};
export async function createLinuxCncInterpreterRuntime({
moduleOptions = null,
tpModuleOptions = null,
wasmRoot = null,
sdkModuleUrl = DEFAULT_SDK_MODULE_URL,
tpSdkModuleUrl = DEFAULT_TP_SDK_MODULE_URL,
} = {}) {
const { createLinuxCncInterpSdk } = await import(sdkModuleUrl);
const resolvedModuleOptions = moduleOptions || await createDefaultModuleOptions({ wasmRoot });
const sdk = await createLinuxCncInterpSdk(resolvedModuleOptions);
const tpRuntime = await createOptionalTpRuntime({ tpModuleOptions, wasmRoot, tpSdkModuleUrl });
return {
apiName: "web-rtcp-5axis-linuxcnc-interpreter-runtime",
loaded: true,
sourceMode: SOURCE_MODE,
semanticBoundary: SEMANTIC_BOUNDARY,
executionContext: "direct",
sdk,
tpRuntime,
readiness() {
return {
apiName: "web-rtcp-5axis-linuxcnc-interpreter-runtime-readiness",
loaded: true,
sourceMode: SOURCE_MODE,
semanticBoundary: SEMANTIC_BOUNDARY,
executionContext: "direct",
runProgramReady: typeof sdk.runProgram === "function",
remapRuntimeReady: false,
plannerRuntimeReady: tpRuntime?.loaded === true,
plannerSemanticBoundary: tpRuntime?.semanticBoundary || null,
};
},
runProgram(programText) {
const prepared = prepareLinuxCncProgramForRuntime(programText);
const resultText = sdk.runProgram(prepared.runtimeProgramText);
const motion = parseLinuxCncCanonicalMotion(resultText, programText, prepared.switchkinsEvents);
return createProgramExecutionResult({
programText,
resultText: prependRuntimeEvents(resultText, prepared.switchkinsEvents),
motion,
switchkinsEvents: prepared.switchkinsEvents,
runtimeProgramText: prepared.runtimeProgramText,
plannerTiming: runPlannerTiming(tpRuntime, motion),
});
},
runMachineFileProgram({ plan, files = null, executionMode = "fiveAxisRemap" } = {}) {
if (!plan?.wasmIniPath || !plan?.wasmProgramPath) {
throw new Error("runMachineFileProgram requires a machine-file staging plan with INI and program paths");
}
if (typeof sdk.runSimConfigProgram !== "function") {
throw new Error("LinuxCNC interpreter SDK missing runSimConfigProgram");
}
const stagedFiles = files || plan.files;
const resultText = sdk.runSimConfigProgram({
files: stagedFiles.map((file) => ({
path: file.wasmPath || file.path,
text: file.text,
executable: file.executable,
})),
programPath: plan.wasmProgramPath,
iniPath: plan.wasmIniPath,
executionMode,
});
const programFile = stagedFiles.find((file) => (
(file.wasmPath || file.path) === plan.wasmProgramPath
));
const programText = programFile?.text || "";
const motion = parseLinuxCncCanonicalMotion(resultText, programText);
return createProgramExecutionResult({
programText,
resultText,
motion,
machineFilePlan: plan,
sourceMode: "linuxcnc-machine-file-remap-wasm",
semanticBoundary: "linuxcnc_fiveaxis_remap_wasm_machine_file_execution",
plannerTiming: runPlannerTiming(tpRuntime, motion),
});
},
};
}
export function createLinuxCncInterpreterRuntimeDescriptor(runtime) {
if (!runtime?.loaded) return null;
return {
apiName: runtime.apiName,
loaded: runtime.loaded,
sourceMode: runtime.sourceMode,
semanticBoundary: runtime.semanticBoundary,
executionContext: runtime.executionContext || "direct",
};
}
function createProgramExecutionResult({
programText,
resultText,
motion,
switchkinsEvents = [],
runtimeProgramText = programText,
machineFilePlan = null,
sourceMode = SOURCE_MODE,
semanticBoundary = SEMANTIC_BOUNDARY,
plannerTiming = null,
}) {
const canonicalEventCount = String(resultText).split("\n").filter((line) => line.startsWith("canon_event=")).length;
const machineFileExecutionReady = Boolean(
machineFilePlan && FIVE_AXIS_REMAP_FLAGS.every((flag) => String(resultText).includes(flag)),
);
const plannerRuntimeReady = plannerTiming?.plannerRuntimeReady === true
&& plannerTiming.motionCount === motion.length;
return {
apiName: "web-rtcp-5axis-linuxcnc-interpreter-program-execution",
sourceMode,
semanticBoundary,
resultText,
runtimeProgramText,
motion,
plannerTiming,
switchkinsEvents,
switchkinsRemapBoundary: switchkinsEvents.length > 0 ? SWITCHKINS_REMAP_BOUNDARY : null,
machineFilePlan: machineFilePlan
? {
apiName: machineFilePlan.apiName,
profileId: machineFilePlan.profileId,
wasmIniPath: machineFilePlan.wasmIniPath,
wasmProgramPath: machineFilePlan.wasmProgramPath,
selectedProgramSourceRel: machineFilePlan.selectedProgramSourceRel || null,
selectedProgramFilename: machineFilePlan.selectedProgramFilename || null,
fileCount: machineFilePlan.files?.length ?? 0,
semanticBoundary: machineFilePlan.semanticBoundary,
}
: null,
summary: {
ready: motion.length > 0,
programLineCount: programText.split(/\r?\n/).filter((line) => line.trim()).length,
canonicalEventCount,
motionEventCount: motion.length,
motionTypes: [...new Set(motion.map((event) => event.type))],
finalAxes: motion.at(-1)?.axes || Object.fromEntries(AXES.map((axis) => [axis, 0])),
switchkinsEventCount: switchkinsEvents.length,
switchkinsCodes: [...new Set(switchkinsEvents.map((event) => event.code))],
switchkinsRemapBoundary: switchkinsEvents.length > 0 ? SWITCHKINS_REMAP_BOUNDARY : null,
remapRuntimeReady: machineFileExecutionReady,
plannerRuntimeReady,
plannerSemanticBoundary: plannerRuntimeReady ? PLANNER_TIMING_BOUNDARY : null,
machineFileExecutionReady,
fullLinuxCncProgramExecutionReady: false,
},
};
}
async function createOptionalTpRuntime({ tpModuleOptions, wasmRoot, tpSdkModuleUrl }) {
try {
const { createLinuxCncTpSdk } = await import(tpSdkModuleUrl);
const resolvedOptions = tpModuleOptions || await createDefaultTpModuleOptions({ wasmRoot });
const sdk = await createLinuxCncTpSdk(resolvedOptions);
return {
loaded: true,
semanticBoundary: PLANNER_TIMING_BOUNDARY,
sdk,
};
} catch (error) {
return {
loaded: false,
semanticBoundary: null,
error: error instanceof Error ? error.message : String(error),
};
}
}
function runPlannerTiming(tpRuntime, motion) {
if (!tpRuntime?.loaded || typeof tpRuntime.sdk?.runCanonicalMotionTiming !== "function") {
return null;
}
try {
return tpRuntime.sdk.runCanonicalMotionTiming({
motion,
options: {
cycleTime: 0.001,
queueSize: 32,
maxCycles: 2000000,
sampleStride: 10,
maxVelocity: 35,
maxAcceleration: 500,
maxJerk: 1000,
tolerance: 0,
},
});
} catch (error) {
return {
apiName: "web-rtcp-5axis-linuxcnc-tp-program-timing",
semanticBoundary: PLANNER_TIMING_BOUNDARY,
plannerRuntimeReady: false,
error: error instanceof Error ? error.message : String(error),
motionCount: motion.length,
totalSeconds: 0,
totalMinutes: 0,
feedSeconds: 0,
rapidSeconds: 0,
segments: [],
};
}
}
export function parseLinuxCncCanonicalMotion(resultText, programText = "", switchkinsEvents = []) {
const axes = Object.fromEntries(AXES.map((axis) => [axis, 0]));
const sourceLines = programLineMap(programText);
const feedRatesByLine = feedRatesBySourceLine(programText);
const switchkinsByLine = switchkinsEventsByLine(switchkinsEvents);
const motion = [];
let activePlane = 170;
let activeSwitchkinsEvent = null;
let activeFeedRate = null;
for (const line of String(resultText).split("\n")) {
const feedRate = readCanonicalNumber(line, "feed_rate");
if (Number.isFinite(feedRate) && feedRate > 0) {
activeFeedRate = feedRate;
}
const plane = readCanonicalNumber(line, "plane");
if (plane && PLANE_AXIS_MAP[plane]) {
activePlane = plane;
}
const event = line.match(/^canon_event=(STRAIGHT_TRAVERSE|STRAIGHT_FEED|ARC_FEED)\b/);
if (!event) continue;
if (event[1] === "ARC_FEED") {
const [firstAxis, secondAxis, thirdAxis] = PLANE_AXIS_MAP[activePlane] ?? PLANE_AXIS_MAP[170];
const firstEnd = readCanonicalNumber(line, "first_end");
const secondEnd = readCanonicalNumber(line, "second_end");
const axisEndPoint = readCanonicalNumber(line, "axis_end_point");
if (Number.isFinite(firstEnd)) axes[firstAxis] = firstEnd;
if (Number.isFinite(secondEnd)) axes[secondAxis] = secondEnd;
if (Number.isFinite(axisEndPoint)) axes[thirdAxis] = axisEndPoint;
axes.arc = {
plane: activePlane,
firstAxis,
secondAxis,
thirdAxis,
firstEnd,
secondEnd,
centerFirst: readCanonicalNumber(line, "first_axis"),
centerSecond: readCanonicalNumber(line, "second_axis"),
rotation: readCanonicalNumber(line, "rotation"),
axisEndPoint,
};
} else {
for (const axis of AXES) {
const value = readCanonicalNumber(line, axis);
if (value !== null && Number.isFinite(value)) axes[axis] = value;
}
}
const sourceLine = readCanonicalNumber(line, "line");
if (Number.isFinite(sourceLine)) {
const event = latestSwitchkinsEventAtOrBeforeLine(switchkinsByLine, sourceLine);
if (event) activeSwitchkinsEvent = event;
const sourceFeedRate = latestFeedRateAtOrBeforeLine(feedRatesByLine, sourceLine);
if (Number.isFinite(sourceFeedRate) && sourceFeedRate > 0) {
activeFeedRate = sourceFeedRate;
}
}
motion.push({
type: event[1],
line: Number.isFinite(sourceLine) ? sourceLine : null,
statement: Number.isFinite(sourceLine) ? (sourceLines.get(sourceLine) ?? "-") : "-",
axes: { ...axes },
kinsType: activeSwitchkinsEvent?.requestedKinsType || null,
switchkinsType: activeSwitchkinsEvent?.switchkinsType ?? null,
switchkinsCode: activeSwitchkinsEvent?.code || null,
switchkinsRemapBoundary: activeSwitchkinsEvent ? SWITCHKINS_REMAP_BOUNDARY : null,
feedRate: activeFeedRate,
raw: line,
});
}
return motion;
}
function feedRatesBySourceLine(programText) {
const rates = [];
String(programText).split(/\r?\n/).forEach((line, index) => {
const codeOnly = stripComments(line);
let feedRate = null;
for (const match of codeOnly.matchAll(/\bF\s*([-+]?\d+(?:\.\d+)?)/gi)) {
const value = Number(match[1]);
if (Number.isFinite(value) && value > 0) feedRate = value;
}
if (feedRate !== null) {
rates.push({ line: index + 1, feedRate });
}
});
return rates;
}
function latestFeedRateAtOrBeforeLine(rates, sourceLine) {
let feedRate = null;
for (const entry of rates) {
if (entry.line <= sourceLine) feedRate = entry.feedRate;
}
return feedRate;
}
export function prepareLinuxCncProgramForRuntime(programText) {
const switchkinsEvents = [];
const runtimeLines = String(programText).split(/\r?\n/).map((line, index) => {
const lineNumber = index + 1;
const events = readSwitchkinsEventsFromLine(line, lineNumber);
if (events.length === 0) return line;
switchkinsEvents.push(...events);
return stripSwitchkinsMcodesFromLine(line, events);
});
return {
runtimeProgramText: runtimeLines.join("\n"),
switchkinsEvents,
};
}
function readSwitchkinsEventsFromLine(line, lineNumber) {
const codeOnly = stripComments(String(line));
const events = [];
for (const match of codeOnly.matchAll(/\bM\s*([0-9]+(?:\.[0-9]+)?)\b/gi)) {
const value = Number(match[1]);
const mCode = Number.isInteger(value) ? value : null;
const switchkins = SWITCHKINS_M_CODES.get(mCode);
if (!switchkins) continue;
events.push({
line: lineNumber,
code: `M${mCode}`,
switchkinsType: switchkins.switchkinsType,
requestedKinsType: switchkins.requestedKinsType,
semanticBoundary: SWITCHKINS_REMAP_BOUNDARY,
});
}
return events;
}
function stripSwitchkinsMcodesFromLine(line, events) {
const eventCodes = new Set(events.map((event) => event.code.slice(1)));
let nextLine = String(line).replace(/\bM\s*([0-9]+(?:\.[0-9]+)?)\b/gi, (token, value) => {
const numericValue = Number(value);
if (Number.isInteger(numericValue) && eventCodes.has(String(numericValue))) {
return " ";
}
return token;
});
const codeOnly = stripComments(nextLine).replace(/\bN\s*[0-9]+\b/gi, "").trim();
if (!codeOnly) {
nextLine = `(web runtime switchkins ${events.map((event) => event.code).join(" ")})`;
}
return nextLine;
}
function stripComments(line) {
return String(line)
.replace(/\([^)]*\)/g, " ")
.replace(/;.*$/g, " ");
}
function switchkinsEventsByLine(events) {
return [...events]
.filter((event) => Number.isFinite(event.line))
.sort((left, right) => left.line - right.line);
}
function latestSwitchkinsEventAtOrBeforeLine(events, lineNumber) {
let latest = null;
for (const event of events) {
if (event.line > lineNumber) break;
latest = event;
}
return latest;
}
function prependRuntimeEvents(resultText, switchkinsEvents) {
if (switchkinsEvents.length === 0) return resultText;
const eventLines = switchkinsEvents.map((event) => (
`web_runtime_event=SWITCHKINS line=${event.line} code=${event.code} switchkins_type=${event.switchkinsType} requested_kins=${event.requestedKinsType}`
));
return `${eventLines.join("\n")}\n${resultText}`;
}
function programLineMap(programText) {
const lines = new Map();
programText.split(/\r?\n/).forEach((line, index) => {
lines.set(index + 1, line.trim() || "(blank)");
});
return lines;
}
function readCanonicalNumber(line, field) {
const match = String(line).match(new RegExp(`\\b${field}=([-+0-9.eE]+)`));
return match ? Number(match[1]) : null;
}
async function createDefaultModuleOptions({ wasmRoot }) {
const quietOptions = { print() {}, printErr() {} };
if (!isNodeRuntime()) return quietOptions;
const [{ readFileSync }, { dirname, resolve }, { fileURLToPath }] = await Promise.all([
import("node:fs"),
import("node:path"),
import("node:url"),
]);
const moduleDir = dirname(fileURLToPath(import.meta.url));
const resolvedWasmRoot = wasmRoot || resolve(moduleDir, "../../../../wasm-port/build/wasm/core");
return {
...quietOptions,
wasmBinary: readFileSync(resolve(resolvedWasmRoot, "linuxcnc_interp.wasm")),
};
}
async function createDefaultTpModuleOptions({ wasmRoot }) {
const quietOptions = { print() {}, printErr() {} };
if (!isNodeRuntime()) return quietOptions;
const [{ readFileSync }, { dirname, resolve }, { fileURLToPath }] = await Promise.all([
import("node:fs"),
import("node:path"),
import("node:url"),
]);
const moduleDir = dirname(fileURLToPath(import.meta.url));
const resolvedWasmRoot = wasmRoot || resolve(moduleDir, "../../../../wasm-port/build/wasm/tp");
return {
...quietOptions,
wasmBinary: readFileSync(resolve(resolvedWasmRoot, "linuxcnc_tp.wasm")),
};
}
function isNodeRuntime() {
return typeof process === "object"
&& typeof process.versions === "object"
&& typeof process.versions.node === "string"
&& process.type !== "renderer";
}

View File

@@ -0,0 +1,80 @@
const SOURCE_MODE = "linuxcnc-interpreter-wasm";
const SEMANTIC_BOUNDARY = "linuxcnc_interpreter_wasm_canonical_events";
export async function createLinuxCncInterpreterWorkerRuntime({
sdkModuleUrl,
workerUrl = new URL("./linuxcnc-interpreter-worker.js", import.meta.url).href,
} = {}) {
if (typeof Worker !== "function") {
throw new Error("Web Worker is not available in this runtime");
}
const worker = new Worker(workerUrl, { type: "module" });
const request = createWorkerRequest(worker);
const readiness = await request("init", { sdkModuleUrl });
const runtime = {
apiName: "web-rtcp-5axis-linuxcnc-interpreter-worker-runtime",
loaded: true,
sourceMode: SOURCE_MODE,
semanticBoundary: SEMANTIC_BOUNDARY,
executionContext: "worker",
workerUrl,
readiness() {
return {
...readiness,
apiName: "web-rtcp-5axis-linuxcnc-interpreter-worker-runtime-readiness",
executionContext: "worker",
workerUrl,
};
},
runProgram(programText) {
return request("runProgram", { programText });
},
runMachineFileProgram(options = {}) {
return request("runMachineFileProgram", options);
},
terminate() {
worker.terminate();
},
};
return runtime;
}
function createWorkerRequest(worker) {
let nextId = 1;
const pending = new Map();
worker.addEventListener("message", (event) => {
const { id, ok, value, error } = event.data || {};
const request = pending.get(id);
if (!request) return;
pending.delete(id);
if (ok) {
request.resolve(value);
} else {
request.reject(new Error(error || "LinuxCNC interpreter worker request failed"));
}
});
worker.addEventListener("error", (event) => {
const error = new Error(event.message || "LinuxCNC interpreter worker error");
for (const request of pending.values()) {
request.reject(error);
}
pending.clear();
});
return function request(type, payload = {}) {
const id = nextId++;
return new Promise((resolve, reject) => {
pending.set(id, { resolve, reject });
worker.postMessage({ id, type, payload });
});
};
}

View File

@@ -0,0 +1,49 @@
import { createLinuxCncInterpreterRuntime } from "./linuxcnc-interpreter-runtime.js";
let runtime = null;
self.addEventListener("message", async (event) => {
const { id, type, payload = {} } = event.data || {};
try {
if (type === "init") {
runtime = await createLinuxCncInterpreterRuntime({
moduleOptions: payload.moduleOptions,
wasmRoot: payload.wasmRoot,
sdkModuleUrl: payload.sdkModuleUrl,
});
postSuccess(id, runtime.readiness());
return;
}
if (!runtime?.loaded) {
throw new Error("LinuxCNC interpreter worker runtime is not initialized");
}
if (type === "readiness") {
postSuccess(id, runtime.readiness());
return;
}
if (type === "runProgram") {
postSuccess(id, runtime.runProgram(payload.programText || ""));
return;
}
if (type === "runMachineFileProgram") {
postSuccess(id, runtime.runMachineFileProgram(payload));
return;
}
throw new Error(`unknown LinuxCNC interpreter worker request: ${type}`);
} catch (error) {
self.postMessage({
id,
ok: false,
error: error instanceof Error ? error.message : String(error),
});
}
});
function postSuccess(id, value) {
self.postMessage({ id, ok: true, value });
}

View File

@@ -1,39 +1,31 @@
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import {
createLinuxCncKinematicsSdk,
linuxCncKinematicsWasmFile,
supportedLinuxCncKinematicsModules,
} from "../../../../wasm-port/runtime/sdk/src/index.js";
const DEFAULT_MODULE_ID = "xyzac-trt"; const DEFAULT_MODULE_ID = "xyzac-trt";
const DEFAULT_JOINT_COUNT = 5; const DEFAULT_JOINT_COUNT = 5;
const SOURCE_MODE = "source-derived-kinematics-wasm"; const SOURCE_MODE = "source-derived-kinematics-wasm";
const SEMANTIC_BOUNDARY = "linuxcnc_kinematics_wasm_c_abi"; const SEMANTIC_BOUNDARY = "linuxcnc_kinematics_wasm_c_abi";
const __dirname = dirname(fileURLToPath(import.meta.url)); const DEFAULT_SDK_MODULE_URL = "../../../../wasm-port/runtime/sdk/src/linuxcnc-kinematics.js";
const defaultWasmRoot = resolve(__dirname, "../../../../wasm-port/build/wasm/kinematics");
export async function createLinuxCncKinematicsRuntime({ export async function createLinuxCncKinematicsRuntime({
moduleId = DEFAULT_MODULE_ID, moduleId = DEFAULT_MODULE_ID,
moduleOptions = null, moduleOptions = null,
switchkinsType = 0, switchkinsType = 0,
jointCount = DEFAULT_JOINT_COUNT, jointCount = DEFAULT_JOINT_COUNT,
wasmRoot = defaultWasmRoot, wasmRoot = null,
sdkModuleUrl = DEFAULT_SDK_MODULE_URL,
} = {}) { } = {}) {
const {
createLinuxCncKinematicsSdk,
linuxCncKinematicsWasmFile,
supportedLinuxCncKinematicsModules,
} = await import(sdkModuleUrl);
const wasmFile = linuxCncKinematicsWasmFile(moduleId); const wasmFile = linuxCncKinematicsWasmFile(moduleId);
if (!wasmFile) { if (!wasmFile) {
throw new Error(`unsupported LinuxCNC kinematics module: ${moduleId}`); throw new Error(`unsupported LinuxCNC kinematics module: ${moduleId}`);
} }
const resolvedModuleOptions = moduleOptions || { const resolvedModuleOptions = moduleOptions || await createDefaultModuleOptions({ wasmRoot, wasmFile });
wasmBinary: readFileSync(resolve(wasmRoot, wasmFile)),
print() {},
printErr() {},
};
const sdk = await createLinuxCncKinematicsSdk({ moduleId, moduleOptions: resolvedModuleOptions }); const sdk = await createLinuxCncKinematicsSdk({ moduleId, moduleOptions: resolvedModuleOptions });
const switchRc = typeof sdk.switchKinematics === "function" let activeSwitchkinsType = switchkinsType;
let activeSwitchRc = typeof sdk.switchKinematics === "function"
? sdk.switchKinematics(switchkinsType) ? sdk.switchKinematics(switchkinsType)
: 0; : 0;
@@ -45,8 +37,13 @@ export async function createLinuxCncKinematicsRuntime({
loaded: true, loaded: true,
sourceMode: SOURCE_MODE, sourceMode: SOURCE_MODE,
semanticBoundary: SEMANTIC_BOUNDARY, semanticBoundary: SEMANTIC_BOUNDARY,
switchkinsType, executionContext: "direct",
switchRc, get switchkinsType() {
return activeSwitchkinsType;
},
get switchRc() {
return activeSwitchRc;
},
jointCount, jointCount,
sdk, sdk,
@@ -59,11 +56,21 @@ export async function createLinuxCncKinematicsRuntime({
loaded: true, loaded: true,
sourceMode: SOURCE_MODE, sourceMode: SOURCE_MODE,
semanticBoundary: SEMANTIC_BOUNDARY, semanticBoundary: SEMANTIC_BOUNDARY,
switchkinsType, executionContext: "direct",
switchRc, switchkinsType: activeSwitchkinsType,
switchRc: activeSwitchRc,
}; };
}, },
switchKinematics(nextSwitchkinsType) {
const requestedType = Number(nextSwitchkinsType) || 0;
activeSwitchRc = typeof sdk.switchKinematics === "function"
? sdk.switchKinematics(requestedType)
: 0;
activeSwitchkinsType = requestedType;
return activeSwitchRc;
},
forward(joints, options = {}) { forward(joints, options = {}) {
return sdk.forward(joints, options); return sdk.forward(joints, options);
}, },
@@ -82,7 +89,7 @@ export async function createLinuxCncKinematicsRuntime({
); );
return { return {
moduleId, moduleId,
switchkinsType, switchkinsType: activeSwitchkinsType,
forward, forward,
inverse, inverse,
}; };
@@ -90,6 +97,35 @@ export async function createLinuxCncKinematicsRuntime({
}; };
} }
async function createDefaultModuleOptions({ wasmRoot, wasmFile }) {
const quietOptions = {
print() {},
printErr() {},
};
if (!isNodeRuntime()) {
return quietOptions;
}
const [{ readFileSync }, { dirname, resolve }, { fileURLToPath }] = await Promise.all([
import("node:fs"),
import("node:path"),
import("node:url"),
]);
const moduleDir = dirname(fileURLToPath(import.meta.url));
const resolvedWasmRoot = wasmRoot || resolve(moduleDir, "../../../../wasm-port/build/wasm/kinematics");
return {
...quietOptions,
wasmBinary: readFileSync(resolve(resolvedWasmRoot, wasmFile)),
};
}
function isNodeRuntime() {
return typeof process === "object"
&& typeof process.versions === "object"
&& typeof process.versions.node === "string"
&& process.type !== "renderer";
}
export function createLinuxCncKinematicsRuntimeDescriptor(runtime) { export function createLinuxCncKinematicsRuntimeDescriptor(runtime) {
if (!runtime?.loaded) return null; if (!runtime?.loaded) return null;
return { return {
@@ -100,6 +136,8 @@ export function createLinuxCncKinematicsRuntimeDescriptor(runtime) {
loaded: runtime.loaded, loaded: runtime.loaded,
sourceMode: runtime.sourceMode, sourceMode: runtime.sourceMode,
semanticBoundary: runtime.semanticBoundary, semanticBoundary: runtime.semanticBoundary,
executionContext: runtime.executionContext || "direct",
workerUrl: runtime.workerUrl || null,
switchkinsType: runtime.switchkinsType, switchkinsType: runtime.switchkinsType,
switchRc: runtime.switchRc, switchRc: runtime.switchRc,
}; };

View File

@@ -0,0 +1,107 @@
const DEFAULT_MODULE_ID = "xyzac-trt";
const DEFAULT_JOINT_COUNT = 5;
const SOURCE_MODE = "source-derived-kinematics-wasm";
const SEMANTIC_BOUNDARY = "linuxcnc_kinematics_wasm_c_abi";
export async function createLinuxCncKinematicsWorkerRuntime({
moduleId = DEFAULT_MODULE_ID,
switchkinsType = 0,
jointCount = DEFAULT_JOINT_COUNT,
sdkModuleUrl,
workerUrl = new URL("./linuxcnc-kinematics-worker.js", import.meta.url).href,
} = {}) {
if (typeof Worker !== "function") {
throw new Error("Web Worker is not available in this runtime");
}
const worker = new Worker(workerUrl, { type: "module" });
const request = createWorkerRequest(worker);
const readiness = await request("init", {
moduleId,
switchkinsType,
jointCount,
sdkModuleUrl,
});
const runtime = {
apiName: "web-rtcp-5axis-linuxcnc-kinematics-worker-runtime",
moduleId,
wasmFile: readiness.wasmFile,
supportedModules: readiness.supportedModules,
loaded: true,
sourceMode: SOURCE_MODE,
semanticBoundary: SEMANTIC_BOUNDARY,
executionContext: "worker",
workerUrl,
switchkinsType,
switchRc: readiness.switchRc,
jointCount,
readiness() {
return {
...readiness,
apiName: "web-rtcp-5axis-linuxcnc-kinematics-worker-runtime-readiness",
executionContext: "worker",
workerUrl,
};
},
forward(joints, options = {}) {
return request("forward", { joints: Array.from(joints, Number), options });
},
inverse(pose, count = jointCount, options = {}) {
return request("inverse", { pose, count, options });
},
async switchKinematics(nextSwitchkinsType) {
const result = await request("switchKinematics", { switchkinsType: nextSwitchkinsType });
runtime.switchkinsType = result.switchkinsType;
runtime.switchRc = result.switchRc;
return result.switchRc;
},
frameForJoints(joints, options = {}) {
return request("frameForJoints", { joints: Array.from(joints, Number), options });
},
terminate() {
worker.terminate();
},
};
return runtime;
}
function createWorkerRequest(worker) {
let nextId = 1;
const pending = new Map();
worker.addEventListener("message", (event) => {
const { id, ok, value, error } = event.data || {};
const request = pending.get(id);
if (!request) return;
pending.delete(id);
if (ok) {
request.resolve(value);
} else {
request.reject(new Error(error || "LinuxCNC kinematics worker request failed"));
}
});
worker.addEventListener("error", (event) => {
const error = new Error(event.message || "LinuxCNC kinematics worker error");
for (const request of pending.values()) {
request.reject(error);
}
pending.clear();
});
return function request(type, payload = {}) {
const id = nextId++;
return new Promise((resolve, reject) => {
pending.set(id, { resolve, reject });
worker.postMessage({ id, type, payload });
});
};
}

View File

@@ -0,0 +1,66 @@
import { createLinuxCncKinematicsRuntime } from "./linuxcnc-kinematics-runtime.js";
let runtime = null;
self.addEventListener("message", async (event) => {
const { id, type, payload = {} } = event.data || {};
try {
if (type === "init") {
runtime = await createLinuxCncKinematicsRuntime({
moduleId: payload.moduleId,
moduleOptions: payload.moduleOptions,
switchkinsType: payload.switchkinsType,
jointCount: payload.jointCount,
wasmRoot: payload.wasmRoot,
sdkModuleUrl: payload.sdkModuleUrl,
});
postSuccess(id, runtime.readiness());
return;
}
if (!runtime?.loaded) {
throw new Error("LinuxCNC kinematics worker runtime is not initialized");
}
if (type === "readiness") {
postSuccess(id, runtime.readiness());
return;
}
if (type === "forward") {
postSuccess(id, runtime.forward(payload.joints, payload.options || {}));
return;
}
if (type === "inverse") {
postSuccess(id, runtime.inverse(payload.pose, payload.count, payload.options || {}));
return;
}
if (type === "switchKinematics") {
const switchRc = runtime.switchKinematics(payload.switchkinsType);
postSuccess(id, {
switchkinsType: runtime.switchkinsType,
switchRc,
});
return;
}
if (type === "frameForJoints") {
postSuccess(id, runtime.frameForJoints(payload.joints, payload.options || {}));
return;
}
throw new Error(`unknown LinuxCNC kinematics worker request: ${type}`);
} catch (error) {
self.postMessage({
id,
ok: false,
error: error instanceof Error ? error.message : String(error),
});
}
});
function postSuccess(id, value) {
self.postMessage({ id, ok: true, value });
}

View File

@@ -0,0 +1,289 @@
const DEFAULT_SDK_MODULE_URL = "../../../../wasm-port/runtime/sdk/src/sim-config-staging.js";
const DEFAULT_MANIFEST_URLS = [
new URL("../../../../wasm-port/tools/source-manifest.txt", import.meta.url).href,
new URL("../../wasm-port/tools/source-manifest.txt", import.meta.url).href,
];
const DEFAULT_VENDOR_ROOT_URLS = [
new URL("../../../../wasm-port/vendor/linuxcnc/", import.meta.url).href,
new URL("../../wasm-port/vendor/linuxcnc/", import.meta.url).href,
];
const TRT_MACHINE_REL = "axis/vismach/5axis/table-rotary-tilting";
const TRT_DEMO_SOURCE_PREFIX = `configs/sim/${TRT_MACHINE_REL}/demos/`;
const OPFS_ROOT = "web-rtcp-5axis-sim-plan/machines";
export async function createMachineFileStagingPlan({
profile,
iniText = null,
manifestText = null,
sdkModuleUrl = DEFAULT_SDK_MODULE_URL,
manifestUrl = null,
wasmDir = null,
} = {}) {
if (!profile?.iniPath) {
throw new Error("machine file staging requires a profile with iniPath");
}
const { planSimConfigStaging } = await import(sdkModuleUrl);
const resolvedManifestText = manifestText ?? await readTextFromCandidateUrls(
manifestUrl ? [manifestUrl] : DEFAULT_MANIFEST_URLS,
);
const resolvedIniText = iniText ?? await readTextFromCandidateUrls(sourceUrlsFor(profile.iniPath));
const iniFile = basename(profile.iniPath);
const plan = planSimConfigStaging({
manifestText: resolvedManifestText,
machineRel: TRT_MACHINE_REL,
iniFile,
iniText: resolvedIniText,
wasmDir: wasmDir || `/work/sim/${TRT_MACHINE_REL}/${profile.id}`,
});
const files = addVendoredDemoSources(plan.files, resolvedManifestText, plan.wasmDir);
return {
apiName: "web-rtcp-5axis-machine-file-staging-plan",
profileId: profile.id,
machineRel: TRT_MACHINE_REL,
iniPath: profile.iniPath,
wasmDir: plan.wasmDir,
wasmIniPath: plan.iniPath,
wasmProgramPath: plan.programPath,
files: files.map((file) => ({
...file,
opfsPath: opfsPathFor(profile.id, file.sourceRel),
kind: classifySourceRel(file.sourceRel),
})),
summary: summarizePlan(files),
semanticBoundary: "linuxcnc_sim_config_file_staging_plan_only",
};
}
export function listLinuxCncGcodeSources(save) {
return [...(save?.files || [])]
.filter((file) => file.kind === "demo" && isLinuxCncFiveAxisGcodeSourceRel(file.sourceRel))
.map((file) => ({
sourceRel: file.sourceRel,
wasmPath: file.wasmPath,
opfsPath: file.opfsPath,
filename: basename(file.sourceRel),
bytes: file.bytes,
label: basename(file.sourceRel).replace(/\.ngc$/i, ""),
sourceMode: "linuxcnc-vendored-5axis-gcode",
semanticBoundary: "linuxcnc_vendored_5axis_gcode_source_file",
}))
.sort((left, right) => left.filename.localeCompare(right.filename));
}
export function selectMachineFileProgram(plan, save, sourceRel) {
if (!isLinuxCncFiveAxisGcodeSourceRel(sourceRel)) {
throw new Error(`5-axis G-code source must come from LinuxCNC source demos: ${sourceRel}`);
}
const selectedFile = (save?.files || []).find((file) => file.sourceRel === sourceRel);
if (!selectedFile) {
throw new Error(`LinuxCNC G-code source not staged: ${sourceRel}`);
}
return {
...plan,
wasmProgramPath: selectedFile.wasmPath || selectedFile.path,
selectedProgramSourceRel: selectedFile.sourceRel,
selectedProgramFilename: basename(selectedFile.sourceRel),
selectedProgramBytes: selectedFile.bytes,
semanticBoundary: "linuxcnc_sim_config_file_staging_plan_with_selected_gcode_source",
};
}
export async function saveMachineFileStagingPlan(plan, options = {}) {
if (plan?.apiName !== "web-rtcp-5axis-machine-file-staging-plan") {
throw new Error("saveMachineFileStagingPlan requires a machine-file staging plan");
}
const savedFiles = [];
for (const file of plan.files) {
const text = await readTextFromCandidateUrls(sourceUrlsFor(file.sourceRel));
await saveTextFile(file.opfsPath, text, options.storage);
savedFiles.push({
sourceRel: file.sourceRel,
opfsPath: file.opfsPath,
wasmPath: file.wasmPath,
path: file.wasmPath,
text,
kind: file.kind,
bytes: text.length,
executable: Boolean(file.executable),
});
}
return {
apiName: "web-rtcp-5axis-machine-file-staging-save",
profileId: plan.profileId,
status: "saved",
savedAt: new Date().toISOString(),
fileCount: savedFiles.length,
opfsRoot: `${OPFS_ROOT}/${plan.profileId}`,
files: savedFiles,
gcodeSources: listLinuxCncGcodeSources({ files: savedFiles }),
summary: summarizeSavedFiles(savedFiles),
semanticBoundary: "opfs_machine_file_text_staging_only",
};
}
export async function stageProfileMachineFiles(profile, options = {}) {
const plan = await createMachineFileStagingPlan({
profile,
iniText: options.iniText,
manifestText: options.manifestText,
sdkModuleUrl: options.sdkModuleUrl,
manifestUrl: options.manifestUrl,
wasmDir: options.wasmDir,
});
const save = await saveMachineFileStagingPlan(plan, { storage: options.storage });
return { plan, save };
}
function summarizePlan(files) {
const kinds = countKinds(files.map((file) => classifySourceRel(file.sourceRel)));
return {
fileCount: files.length,
requiredFileCount: files.filter((file) => file.sourceRel.endsWith(".ini") || file.sourceRel.includes("/demos/")).length,
remapFileCount: kinds.remap || 0,
demoFileCount: kinds.demo || 0,
toolTableFileCount: kinds.toolTable || 0,
halFileCount: kinds.hal || 0,
kinds,
};
}
function addVendoredDemoSources(files, manifestText, wasmDir) {
const bySourceRel = new Map(files.map((file) => [file.sourceRel, file]));
for (const sourceRel of String(manifestText).split(/\r?\n/)) {
if (!isLinuxCncFiveAxisGcodeSourceRel(sourceRel)) continue;
if (bySourceRel.has(sourceRel)) continue;
bySourceRel.set(sourceRel, {
sourceRel,
wasmPath: `${wasmDir}/demos/${basename(sourceRel)}`,
executable: false,
});
}
return [...bySourceRel.values()];
}
function isLinuxCncFiveAxisGcodeSourceRel(sourceRel) {
const value = String(sourceRel || "");
return value.startsWith(TRT_DEMO_SOURCE_PREFIX)
&& value.endsWith(".ngc")
&& !value.slice(TRT_DEMO_SOURCE_PREFIX.length).includes("/");
}
function summarizeSavedFiles(files) {
return {
fileCount: files.length,
totalBytes: files.reduce((total, file) => total + file.bytes, 0),
kinds: countKinds(files.map((file) => file.kind)),
opfsPaths: files.map((file) => file.opfsPath),
};
}
function countKinds(kinds) {
return kinds.reduce((counts, kind) => {
counts[kind] = (counts[kind] || 0) + 1;
return counts;
}, {});
}
function classifySourceRel(sourceRel) {
if (sourceRel.endsWith(".ini")) return "ini";
if (sourceRel.endsWith(".tbl")) return "toolTable";
if (sourceRel.endsWith(".hal")) return "hal";
if (sourceRel.includes("/remap_subs/")) return "remap";
if (sourceRel.includes("/demos/")) return "demo";
if (sourceRel.endsWith(".xml")) return "pyvcp";
if (sourceRel.endsWith(".var")) return "parameters";
return "asset";
}
function opfsPathFor(profileId, sourceRel) {
return `${OPFS_ROOT}/${assertPathSegment(profileId)}/${String(sourceRel).replaceAll("\\", "/")}`;
}
async function readTextFromCandidateUrls(urls) {
const errors = [];
for (const url of urls) {
try {
return await readTextFromUrl(url);
} catch (error) {
errors.push(`${url}: ${error.message}`);
}
}
throw new Error(`failed to read machine staging asset: ${errors.join(" | ")}`);
}
async function readTextFromUrl(url) {
if (isNodeRuntime()) {
const [{ readFile }, { resolve }, { fileURLToPath }] = await Promise.all([
import("node:fs/promises"),
import("node:path"),
import("node:url"),
]);
const path = String(url).startsWith("file:")
? fileURLToPath(url)
: resolve(process.cwd(), url);
return readFile(path, "utf8");
}
const response = await fetch(url);
if (!response.ok) {
throw new Error(`failed to fetch machine staging asset ${url}: ${response.status}`);
}
return response.text();
}
async function saveTextFile(path, text, storage = globalThis.navigator?.storage) {
const root = await getStorageRoot(storage);
const dir = await ensureParentDir(root, path);
const filename = splitPath(path).at(-1);
const fileHandle = await dir.getFileHandle(filename, { create: true });
const writable = await fileHandle.createWritable();
await writable.write(text);
await writable.close();
}
async function getStorageRoot(storage) {
if (!storage?.getDirectory) {
throw new Error("OPFS is not available in this browser.");
}
return storage.getDirectory();
}
async function ensureParentDir(root, path) {
let current = root;
for (const part of splitPath(path).slice(0, -1)) {
current = await current.getDirectoryHandle(part, { create: true });
}
return current;
}
function splitPath(path) {
const parts = String(path || "").replaceAll("\\", "/").split("/").filter(Boolean);
if (parts.length === 0 || parts.some((part) => part === "." || part === "..")) {
throw new Error(`Invalid OPFS path: ${path}`);
}
return parts;
}
function sourceUrlsFor(sourceRel) {
return DEFAULT_VENDOR_ROOT_URLS.map((rootUrl) => new URL(sourceRel, rootUrl).href);
}
function basename(path) {
return String(path).split("/").filter(Boolean).at(-1);
}
function assertPathSegment(segment) {
if (!/^[a-z0-9._-]+$/i.test(String(segment))) {
throw new Error(`Invalid OPFS path segment: ${segment}`);
}
return segment;
}
function isNodeRuntime() {
return typeof process === "object"
&& typeof process.versions === "object"
&& typeof process.versions.node === "string"
&& process.type !== "renderer";
}

View File

@@ -25,7 +25,7 @@ export function buildRtcpFrame({
const pose = normalizeAxisPose(axisPose); const pose = normalizeAxisPose(axisPose);
const toolLength = 84.019; const toolLength = 84.019;
const toolAxisVector = computeToolAxisVector(pose.a, pose.c); const toolAxisVector = computeToolAxisVector(pose, profile);
const compensation = rtcpEnabled const compensation = rtcpEnabled
? { ? {
x: -toolAxisVector.x * toolLength, x: -toolAxisVector.x * toolLength,
@@ -85,7 +85,7 @@ function buildLinuxCncKinematicsFrame({
...wasmPose, ...wasmPose,
}); });
const jointValues = Array.isArray(inverse.joints) ? inverse.joints : []; const jointValues = Array.isArray(inverse.joints) ? inverse.joints : [];
const toolAxisVector = computeToolAxisVector(pose.a, pose.c); const toolAxisVector = computeToolAxisVector(pose, profile);
return { return {
apiName: "web-rtcp-5axis-motion-frame", apiName: "web-rtcp-5axis-motion-frame",
@@ -97,7 +97,7 @@ function buildLinuxCncKinematicsFrame({
rtcpEnabled, rtcpEnabled,
rtcpState: rtcpEnabled ? "on" : "off", rtcpState: rtcpEnabled ? "on" : "off",
axisPose: pose, axisPose: pose,
jointPose: buildJointPoseFromLinuxCncJoints(jointValues, pose), jointPose: buildJointPoseFromLinuxCncJoints(jointValues, pose, profile),
tcpPose: { tcpPose: {
x: pose.x, x: pose.x,
y: pose.y, y: pose.y,
@@ -150,9 +150,9 @@ function buildJointPose(pose) {
]; ];
} }
function buildJointPoseFromLinuxCncJoints(joints, fallbackPose) { function buildJointPoseFromLinuxCncJoints(joints, fallbackPose, profile = xyzacTrtProfile) {
const axes = ["X", "Y", "Z", "A", "C"]; const axes = profile?.traj?.coordinates === "XYZBC" ? ["X", "Y", "Z", "B", "C"] : ["X", "Y", "Z", "A", "C"];
const fallbackValues = [fallbackPose.x, fallbackPose.y, fallbackPose.z, fallbackPose.a, fallbackPose.c]; const fallbackValues = axes.map((axis) => fallbackPose[axis.toLowerCase()] ?? 0);
return axes.map((axis, joint) => ({ return axes.map((axis, joint) => ({
joint, joint,
axis, axis,
@@ -171,18 +171,29 @@ function normalizeLinuxCncPose(pose = {}) {
}; };
} }
function computeToolAxisVector(aDegrees, cDegrees) { function computeToolAxisVector(pose, profile = xyzacTrtProfile) {
const a = aDegrees * DEG_TO_RAD; const coordinates = profile?.traj?.coordinates || "XYZAC";
const tiltDegrees = coordinates.includes("B") ? pose.b : pose.a;
const cDegrees = pose.c;
const tilt = tiltDegrees * DEG_TO_RAD;
const c = cDegrees * DEG_TO_RAD; const c = cDegrees * DEG_TO_RAD;
const sinA = Math.sin(a); const sinTilt = Math.sin(tilt);
const cosA = Math.cos(a); const cosTilt = Math.cos(tilt);
const sinC = Math.sin(c); const sinC = Math.sin(c);
const cosC = Math.cos(c); const cosC = Math.cos(c);
if (coordinates.includes("B")) {
return normalizeVector({
x: sinTilt * cosC,
y: sinTilt * sinC,
z: cosTilt,
});
}
return normalizeVector({ return normalizeVector({
x: sinA * sinC, x: sinTilt * sinC,
y: -sinA * cosC, y: -sinTilt * cosC,
z: cosA, z: cosTilt,
}); });
} }

View File

@@ -0,0 +1,161 @@
export const LINUXCNC_TASK_POLICY = {
apiName: "web-rtcp-5axis-linuxcnc-task-policy",
sourceMode: "linuxcnc-task-source-referenced-policy",
semanticBoundary: "linuxcnc_task_state_mode_command_gate",
sourceReferences: [
{
path: "linuxcnc/src/emc/nml_intf/emc.hh",
symbols: ["EMC_TASK_MODE", "EMC_TASK_STATE", "EMC_TASK_INTERP"],
},
{
path: "linuxcnc/src/emc/task/emctaskmain.cc",
symbols: [
"emcTaskPlan",
"EMC_TASK_PLAN_RUN",
"EMC_TASK_PLAN_EXECUTE",
"EMC_TASK_PLAN_PAUSE",
"EMC_TASK_PLAN_RESUME",
"EMC_TASK_ABORT",
"EMC_JOG_INCR",
"EMC_JOINT_HOME",
],
},
{
path: "linuxcnc/src/emc/task/emctask.cc",
symbols: ["emcTaskSetState", "determineState"],
},
],
};
export const LINUXCNC_TASK_MODES = new Set(["manual", "auto", "mdi"]);
export const LINUXCNC_TASK_STATES = new Set(["estop", "estop-reset", "off", "on"]);
export const LINUXCNC_INTERP_STATES = new Set(["idle", "reading", "paused", "waiting"]);
export function normalizeLinuxCncTaskMode(mode) {
if (mode === "jog") return "manual";
return LINUXCNC_TASK_MODES.has(mode) ? mode : "manual";
}
export function normalizeLinuxCncTaskState(machine = {}) {
if (LINUXCNC_TASK_STATES.has(machine.taskState)) return machine.taskState;
if (machine.estopActive) return "estop";
if (machine.powerOn) return "on";
return "estop-reset";
}
export function normalizeLinuxCncInterpState(machine = {}, runState = "idle") {
if (LINUXCNC_INTERP_STATES.has(machine.interpState)) return machine.interpState;
if (runState === "running") return "reading";
if (runState === "paused" || runState === "stepping") return "paused";
return "idle";
}
export function createLinuxCncTaskPolicyStatus(state) {
const taskState = normalizeLinuxCncTaskState(state.machine);
const taskMode = normalizeLinuxCncTaskMode(state.machine.mode);
const interpState = normalizeLinuxCncInterpState(state.machine, state.runState);
const allHomed = Boolean(state.machine.allHomed);
const noForceHoming = Boolean(state.machine.noForceHoming);
return {
...LINUXCNC_TASK_POLICY,
taskState,
taskMode,
interpState,
allHomed,
noForceHoming,
powerOn: taskState === "on",
estopActive: taskState === "estop",
canMove: taskState === "on",
canJog: taskState === "on" && taskMode === "manual",
canHome: taskState === "on" && taskMode === "manual",
canRunAuto: taskState === "on" && taskMode === "auto" && (allHomed || noForceHoming),
canExecuteMdi: taskState === "on" && taskMode === "mdi" && (allHomed || noForceHoming),
canPause: taskState === "on" && (taskMode === "auto" || taskMode === "mdi"),
canResume: taskState === "on" && (taskMode === "auto" || taskMode === "mdi") && interpState === "paused",
canAbort: true,
canLeaveAuto: taskMode !== "auto" || interpState === "idle",
};
}
export function gateLinuxCncTaskAction(state, action) {
const status = createLinuxCncTaskPolicyStatus(state);
const type = typeof action === "string" ? action : action?.type;
const requestedMode = typeof action === "object" ? action.mode : undefined;
switch (type) {
case "TOGGLE_POWER":
if (status.taskState === "estop") {
return block(status, "power on blocked: reset estop first");
}
return allow(status);
case "SET_MODE":
return gateMode(status, requestedMode);
case "JOG":
if (status.taskState !== "on") return block(status, "jog blocked: machine must be on");
if (status.taskMode !== "manual") return block(status, "jog blocked: switch to manual mode first");
return allow(status);
case "HOME":
if (status.taskState !== "on") return block(status, "home blocked: machine must be on");
if (status.taskMode !== "manual") return block(status, "home blocked: switch to manual mode first");
return allow(status);
case "RUN_MDI":
if (status.taskState !== "on") return block(status, "MDI blocked: machine must be on");
if (status.taskMode !== "mdi") return block(status, "MDI blocked: switch to MDI mode first");
if (!status.allHomed && !status.noForceHoming) return block(status, "MDI blocked: home machine first");
return allow(status);
case "RUN":
case "STEP":
case "RUN_FRAME":
if (status.taskState !== "on") return block(status, `${type.toLowerCase()} blocked: machine must be on`);
if (status.taskMode !== "auto") return block(status, `${type.toLowerCase()} blocked: switch to auto mode first`);
if (!status.allHomed && !status.noForceHoming) return block(status, `${type.toLowerCase()} blocked: home machine first`);
if (type === "RUN" && status.interpState === "paused") return block(status, "run blocked: resume paused program first");
return allow(status);
case "PAUSE":
if (status.taskState !== "on") return block(status, "pause blocked: machine must be on");
if (status.taskMode !== "auto" && status.taskMode !== "mdi") {
return block(status, "pause blocked: task mode must be auto or MDI");
}
return allow(status);
case "RESUME":
if (status.taskState !== "on") return block(status, "resume blocked: machine must be on");
if (status.taskMode !== "auto" && status.taskMode !== "mdi") {
return block(status, "resume blocked: task mode must be auto or MDI");
}
if (status.interpState !== "paused") return block(status, "resume blocked: interpreter is not paused");
return allow(status);
case "STOP":
case "ABORT":
return allow(status);
default:
return allow(status);
}
}
function gateMode(status, requestedMode) {
const targetMode = normalizeLinuxCncTaskMode(requestedMode);
if (!LINUXCNC_TASK_MODES.has(targetMode)) {
return block(status, `mode blocked: invalid LinuxCNC task mode ${requestedMode}`);
}
if (status.taskMode === "auto" && status.interpState !== "idle" && targetMode !== "auto") {
return block(status, "mode blocked: AUTO interpreter is not idle");
}
return allow(status);
}
function allow(status) {
return {
allowed: true,
status,
operatorMessage: null,
};
}
function block(status, operatorMessage) {
return {
allowed: false,
status,
operatorMessage,
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,9 @@
--green-dark: #02bf19; --green-dark: #02bf19;
--black: #050505; --black: #050505;
--text: #2e2e2e; --text: #2e2e2e;
--ink: #242424;
--line: #aaa59b;
--muted: #5e5a52;
font-family: Arial, Helvetica, sans-serif; font-family: Arial, Helvetica, sans-serif;
} }
@@ -19,11 +22,11 @@
html, html,
body { body {
width: 100%; width: 100%;
min-width: 1024px; min-width: 1180px;
min-height: 768px; min-height: 640px;
height: 100%; height: 100%;
margin: 0; margin: 0;
overflow: auto; overflow: hidden;
background: #c8c4bc; background: #c8c4bc;
color: var(--text); color: var(--text);
} }
@@ -40,26 +43,44 @@ button:active {
transform: translateY(1px); transform: translateY(1px);
} }
.profile-select-label {
display: grid;
gap: 1px;
min-width: 210px;
color: var(--muted);
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
}
.profile-select-label select {
min-height: 28px;
border: 1px solid var(--line);
border-radius: 3px;
background: #f7f5ef;
color: var(--ink);
font: inherit;
font-size: 12px;
}
.gmoccapy-shell { .gmoccapy-shell {
display: grid; display: grid;
grid-template-columns: grid-template-columns:
minmax(350px, 0.76fr) minmax(610px, 1.34fr)
minmax(160px, 0.36fr) minmax(270px, 0.58fr)
minmax(128px, 0.28fr) minmax(260px, 0.56fr)
78px 108px;
minmax(176px, 0.4fr) grid-template-rows: 40px minmax(210px, 1fr) minmax(170px, 0.78fr) 150px 68px;
104px;
grid-template-rows: 28px minmax(156px, 0.36fr) minmax(240px, 0.64fr) 224px 62px;
grid-template-areas: grid-template-areas:
"title title title title title title" "title title title title"
"preview preview dro dro dro side" "preview dro dro side"
"preview preview gcode gcode gcode side" "preview gcode gcode side"
"info override override spindle spindle side" "info override spindle side"
"bottom bottom bottom bottom bottom side"; "bottom bottom bottom side";
width: 100vw; width: 100vw;
height: 100vh; height: 100vh;
min-width: 1024px; min-width: 1180px;
min-height: 768px; min-height: 640px;
border: 1px solid var(--border); border: 1px solid var(--border);
background: var(--panel); background: var(--panel);
} }
@@ -70,7 +91,7 @@ button:active {
align-items: center; align-items: center;
gap: 10px; gap: 10px;
min-width: 0; min-width: 0;
padding: 2px 10px; padding: 4px 12px;
background: #cfcbc3; background: #cfcbc3;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
} }
@@ -78,8 +99,9 @@ button:active {
.brand-dot { .brand-dot {
display: grid; display: grid;
place-items: center; place-items: center;
width: 22px; flex: 0 0 auto;
height: 22px; width: 26px;
height: 26px;
border: 2px solid #ffcf00; border: 2px solid #ffcf00;
border-radius: 50%; border-radius: 50%;
color: #e21d1d; color: #e21d1d;
@@ -98,7 +120,7 @@ button:active {
.title-stack strong { .title-stack strong {
overflow: hidden; overflow: hidden;
font-size: 14px; font-size: 17px;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
@@ -106,7 +128,7 @@ button:active {
.title-stack span, .title-stack span,
.run-state { .run-state {
overflow: hidden; overflow: hidden;
font-size: 12px; font-size: 14px;
color: #4d4d4d; color: #4d4d4d;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
@@ -140,8 +162,9 @@ button:active {
.machine-preview { .machine-preview {
width: 100%; width: 100%;
height: calc(100% - 54px); height: calc(100% - 64px);
margin-top: 24px; margin-top: 32px;
display: block;
} }
.tool-preview-card { .tool-preview-card {
@@ -215,7 +238,7 @@ button:active {
.rtcp-preview-badge { .rtcp-preview-badge {
position: absolute; position: absolute;
right: 8px; right: 8px;
bottom: 58px; bottom: 62px;
left: 8px; left: 8px;
z-index: 2; z-index: 2;
overflow: hidden; overflow: hidden;
@@ -242,8 +265,8 @@ button:active {
left: 0; left: 0;
display: grid; display: grid;
grid-template-columns: repeat(5, 1fr); grid-template-columns: repeat(5, 1fr);
gap: 4px; gap: 6px;
padding: 4px; padding: 6px;
background: var(--panel); background: var(--panel);
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
} }
@@ -251,7 +274,7 @@ button:active {
.preview-toolbar button, .preview-toolbar button,
.bottom-controls button, .bottom-controls button,
.status-sidebar button { .status-sidebar button {
min-height: 46px; min-height: 42px;
font-weight: 700; font-weight: 700;
} }
@@ -262,7 +285,8 @@ button:active {
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
overflow: hidden; overflow: hidden;
border-bottom: 1px solid #151515; border-left: 2px solid #8d887f;
border-bottom: 2px solid #8d887f;
background: var(--black); background: var(--black);
} }
@@ -277,17 +301,17 @@ button:active {
.dro-row { .dro-row {
display: grid; display: grid;
grid-template-columns: 24px 40px minmax(74px, 1fr) 48px; grid-template-columns: 30px 46px minmax(92px, 1fr) 58px;
align-items: center; align-items: center;
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
padding: 3px 5px; padding: 4px 7px;
background: var(--black); background: var(--black);
color: var(--green); color: var(--green);
} }
.dro-axis { .dro-axis {
font-size: 22px; font-size: clamp(24px, 2vw, 32px);
font-weight: 800; font-weight: 800;
line-height: 1; line-height: 1;
} }
@@ -295,14 +319,14 @@ button:active {
.dro-mode, .dro-mode,
.dro-dtg { .dro-dtg {
color: var(--green); color: var(--green);
font-size: 9px; font-size: 10px;
line-height: 1.25; line-height: 1.25;
} }
.dro-row strong { .dro-row strong {
overflow: hidden; overflow: hidden;
text-align: right; text-align: right;
font-size: clamp(20px, 2.4vw, 30px); font-size: clamp(24px, 2.5vw, 38px);
line-height: 1; line-height: 1;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
text-overflow: clip; text-overflow: clip;
@@ -314,10 +338,10 @@ button:active {
gap: 6px; gap: 6px;
justify-content: space-between; justify-content: space-between;
min-width: 0; min-width: 0;
padding: 5px 8px; padding: 6px 9px;
background: #080808; background: #080808;
color: var(--green); color: var(--green);
font-size: 12px; font-size: 13px;
font-weight: 700; font-weight: 700;
} }
@@ -343,11 +367,11 @@ button:active {
.gcode-panel { .gcode-panel {
grid-area: gcode; grid-area: gcode;
display: grid; display: grid;
grid-template-rows: 30px minmax(0, 1fr) 22px; grid-template-rows: 32px minmax(0, 1fr) 20px 54px;
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
overflow: hidden; overflow: hidden;
background: #f4f2ed; background: #f6f4ef;
border-top: 2px solid var(--border); border-top: 2px solid var(--border);
border-bottom: 2px solid var(--border); border-bottom: 2px solid var(--border);
} }
@@ -358,10 +382,10 @@ button:active {
gap: 8px; gap: 8px;
align-items: center; align-items: center;
min-width: 0; min-width: 0;
padding: 5px 8px; padding: 6px 9px;
border-bottom: 1px solid #d5d0c7; border-bottom: 1px solid #d5d0c7;
background: #eeeae3; background: #eeeae3;
font-size: 12px; font-size: 13px;
} }
.gcode-header strong, .gcode-header strong,
@@ -380,27 +404,32 @@ button:active {
.gcode-list { .gcode-list {
min-height: 0; min-height: 0;
margin: 0; margin: 0;
padding: 4px 6px 2px; padding: 6px 8px 3px;
overflow: auto; overflow: auto;
list-style: none; list-style: none;
font-family: "Courier New", monospace; font-family: "Courier New", monospace;
font-size: 15px; font-size: 13px;
color: #8b8b8b; color: #55514a;
} }
.gcode-row { .gcode-row {
display: grid; display: grid;
grid-template-columns: 42px minmax(0, 1fr); grid-template-columns: 42px minmax(0, 1fr);
gap: 6px; gap: 6px;
min-height: 20px; min-height: 18px;
line-height: 1.35; line-height: 1.25;
} }
.gcode-row.active { .gcode-row.active {
background: #e8e8e8; background: #242424;
color: #202020; color: #202020;
} }
.gcode-row.active span,
.gcode-row.active code {
color: #f2f2f2;
}
.gcode-row code { .gcode-row code {
overflow: hidden; overflow: hidden;
white-space: nowrap; white-space: nowrap;
@@ -412,8 +441,8 @@ button:active {
grid-template-columns: 120px 1fr; grid-template-columns: 120px 1fr;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
padding: 2px 8px 4px; padding: 1px 8px 3px;
font-size: 12px; font-size: 11px;
color: #777; color: #777;
} }
@@ -428,10 +457,70 @@ button:active {
background: #3888df; background: #3888df;
} }
.mdi-panel {
display: grid;
grid-template-rows: 27px minmax(0, 1fr);
gap: 2px;
min-width: 0;
min-height: 0;
padding: 3px 8px 5px;
border-top: 1px solid #cbc6bd;
background: #e7e3dc;
}
.mdi-command-row {
display: grid;
grid-template-columns: 44px minmax(0, 1fr) 64px;
gap: 6px;
align-items: center;
min-width: 0;
}
.mdi-command-row strong {
color: #222;
font-size: 13px;
}
.mdi-command-row input {
min-width: 0;
height: 25px;
padding: 3px 8px;
border: 1px solid #8e897f;
border-radius: 3px;
background: #111;
color: var(--green);
font: 14px/1.1 "Courier New", monospace;
}
.mdi-command-row button {
min-height: 25px;
font-size: 12px;
font-weight: 700;
}
.mdi-history {
display: flex;
gap: 4px;
min-width: 0;
overflow: hidden;
}
.mdi-history button {
flex: 0 1 auto;
min-width: 0;
min-height: 19px;
padding: 2px 6px;
overflow: hidden;
color: #303030;
font: 11px/1.1 "Courier New", monospace;
text-overflow: ellipsis;
white-space: nowrap;
}
.status-sidebar { .status-sidebar {
grid-area: side; grid-area: side;
display: grid; display: grid;
grid-template-rows: repeat(9, minmax(44px, 1fr)) minmax(46px, auto); grid-template-rows: repeat(9, minmax(42px, 1fr)) minmax(42px, auto);
gap: 5px; gap: 5px;
padding: 6px; padding: 6px;
border-left: 2px solid var(--border); border-left: 2px solid var(--border);
@@ -475,7 +564,7 @@ button:active {
grid-area: info; grid-area: info;
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
overflow: hidden; overflow: auto;
border-top: 2px solid var(--border); border-top: 2px solid var(--border);
border-right: 2px solid var(--border); border-right: 2px solid var(--border);
background: #eeeae3; background: #eeeae3;
@@ -492,9 +581,9 @@ button:active {
border-right: 1px solid var(--border); border-right: 1px solid var(--border);
border-radius: 0; border-radius: 0;
background: #e0ddd6; background: #e0ddd6;
min-height: 37px; min-height: 32px;
padding: 4px 5px; padding: 4px 5px;
font-size: 14px; font-size: 12px;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
@@ -507,10 +596,10 @@ button:active {
.info-grid { .info-grid {
display: grid; display: grid;
grid-template-columns: max-content 1fr; grid-template-columns: max-content 1fr;
gap: 3px 10px; gap: 1px 8px;
margin: 8px; margin: 6px 8px;
font-size: 14px; font-size: 12px;
line-height: 1.22; line-height: 1.15;
} }
.info-grid dt { .info-grid dt {
@@ -533,7 +622,7 @@ button:active {
grid-area: override; grid-area: override;
display: grid; display: grid;
grid-template-columns: 1fr; grid-template-columns: 1fr;
grid-template-rows: 56px 64px minmax(78px, 1fr); grid-template-rows: repeat(3, minmax(0, 1fr));
gap: 6px; gap: 6px;
padding: 6px; padding: 6px;
border-top: 2px solid var(--border); border-top: 2px solid var(--border);
@@ -549,8 +638,8 @@ button:active {
.spindle { .spindle {
display: grid; display: grid;
align-content: start; align-content: start;
gap: 2px; gap: 1px;
padding: 5px; padding: 5px 6px;
background: #f8f5ef; background: #f8f5ef;
border: 1px solid var(--border); border: 1px solid var(--border);
min-width: 0; min-width: 0;
@@ -563,7 +652,7 @@ button:active {
.cooling h2, .cooling h2,
.spindle h2 { .spindle h2 {
margin: 0; margin: 0;
font-size: 14px; font-size: 12px;
line-height: 1.15; line-height: 1.15;
text-align: center; text-align: center;
} }
@@ -573,7 +662,7 @@ button:active {
.spindle strong { .spindle strong {
display: block; display: block;
overflow: hidden; overflow: hidden;
font-size: clamp(18px, 1.8vw, 25px); font-size: clamp(16px, 1.45vw, 22px);
line-height: 1.05; line-height: 1.05;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
@@ -591,20 +680,20 @@ button:active {
.meter-card strong { .meter-card strong {
display: inline; display: inline;
font-size: clamp(20px, 2vw, 25px); font-size: clamp(18px, 1.6vw, 24px);
line-height: 1; line-height: 1;
} }
.meter-card span { .meter-card span {
padding-left: 5px; padding-left: 5px;
font-size: 14px; font-size: 12px;
line-height: 1.1; line-height: 1.1;
white-space: nowrap; white-space: nowrap;
} }
.stepper { .stepper {
display: grid; display: grid;
grid-template-columns: 32px minmax(54px, 1fr) 32px; grid-template-columns: 30px minmax(54px, 1fr) 30px;
gap: 2px; gap: 2px;
align-items: center; align-items: center;
margin-top: 1px; margin-top: 1px;
@@ -612,7 +701,7 @@ button:active {
.stepper div { .stepper div {
min-width: 0; min-width: 0;
padding: 6px 4px; padding: 5px 4px;
background: var(--orange); background: var(--orange);
border: 1px solid #b4651c; border: 1px solid #b4651c;
text-align: center; text-align: center;
@@ -623,7 +712,7 @@ button:active {
.stepper button { .stepper button {
min-width: 0; min-width: 0;
min-height: 28px; min-height: 26px;
padding: 2px 4px; padding: 2px 4px;
} }
@@ -650,8 +739,8 @@ button:active {
} }
.spindle-range { .spindle-range {
height: 24px; height: 18px;
margin-top: 8px; margin-top: 4px;
background: #bdbdbd; background: #bdbdbd;
border: 1px solid #898989; border: 1px solid #898989;
} }
@@ -665,7 +754,7 @@ button:active {
.bottom-controls { .bottom-controls {
grid-area: bottom; grid-area: bottom;
display: grid; display: grid;
grid-template-columns: repeat(13, minmax(48px, 1fr)); grid-template-columns: repeat(15, minmax(48px, 1fr));
gap: 5px; gap: 5px;
padding: 6px 8px; padding: 6px 8px;
border-top: 2px solid var(--border); border-top: 2px solid var(--border);
@@ -674,8 +763,11 @@ button:active {
} }
.bottom-controls button { .bottom-controls button {
font-size: 12px; font-size: clamp(10px, 0.72vw, 12px);
line-height: 1.05;
min-width: 0; min-width: 0;
padding: 3px 4px;
white-space: normal;
} }
.program-file-input { .program-file-input {
@@ -684,7 +776,11 @@ button:active {
@media (max-width: 1180px) { @media (max-width: 1180px) {
.gmoccapy-shell { .gmoccapy-shell {
grid-template-columns: 350px 160px 128px 78px 176px 96px; grid-template-columns:
minmax(560px, 1.28fr)
minmax(250px, 0.58fr)
minmax(230px, 0.54fr)
100px;
} }
.dro-row strong { .dro-row strong {

View File

@@ -32,6 +32,9 @@ export function mountGmoccapyShell(root, store) {
); );
store.subscribe((state) => render(regions, state, store.dispatch)); store.subscribe((state) => render(regions, state, store.dispatch));
root.addEventListener("profile-change", (event) => {
store.dispatch({ type: "SET_PROFILE", profileId: event.detail.profileId });
});
return { return {
getRegions() { getRegions() {
@@ -46,7 +49,7 @@ function render(regions, state, dispatch) {
renderTitlebar(regions.titlebar, state); renderTitlebar(regions.titlebar, state);
renderPreview(regions.preview, state, dispatch); renderPreview(regions.preview, state, dispatch);
renderDro(regions.dro, state); renderDro(regions.dro, state);
renderGcode(regions.gcode, state); renderGcode(regions.gcode, state, dispatch);
renderSidebar(regions["status-sidebar"], state, dispatch); renderSidebar(regions["status-sidebar"], state, dispatch);
renderInfoTabs(regions["info-tabs"], state); renderInfoTabs(regions["info-tabs"], state);
renderOverride(regions.override, state, dispatch); renderOverride(regions.override, state, dispatch);
@@ -61,8 +64,22 @@ function renderTitlebar(element, state) {
<strong>gmoccapy Web 5 Axis for LinuxCNC RTCP Simulation</strong> <strong>gmoccapy Web 5 Axis for LinuxCNC RTCP Simulation</strong>
<span>${state.machineProfile} | ${state.sessionName} | ${state.sourceMode} | ${state.machine.mode}</span> <span>${state.machineProfile} | ${state.sessionName} | ${state.sourceMode} | ${state.machine.mode}</span>
</div> </div>
<label class="profile-select-label">
Profile
<select data-action="select-profile">
${state.availableProfiles.map((profile) => `
<option value="${profile.id}" ${profile.id === state.machineProfile ? "selected" : ""}>${profile.coordinates} ${profile.id}</option>
`).join("")}
</select>
</label>
<div class="run-state" data-run-state="${state.runState}">${state.runState}</div> <div class="run-state" data-run-state="${state.runState}">${state.runState}</div>
`; `;
element.querySelector('[data-action="select-profile"]').addEventListener("change", (event) => {
element.dispatchEvent(new CustomEvent("profile-change", {
bubbles: true,
detail: { profileId: event.target.value },
}));
});
} }
function renderPreview(element, state, dispatch) { function renderPreview(element, state, dispatch) {
@@ -137,7 +154,7 @@ function droRow(axis, value, dtg) {
`; `;
} }
function renderGcode(element, state) { function renderGcode(element, state, dispatch) {
const rows = state.programLines const rows = state.programLines
.map((line, index) => { .map((line, index) => {
const lineNumber = state.programStartLine + index; const lineNumber = state.programStartLine + index;
@@ -158,12 +175,101 @@ function renderGcode(element, state) {
<span data-program-source="${state.programSource}">${state.programSource}</span> <span data-program-source="${state.programSource}">${state.programSource}</span>
<span data-active-program-line="${state.activeLine}">Current line ${state.activeLine}</span> <span data-active-program-line="${state.activeLine}">Current line ${state.activeLine}</span>
</div> </div>
<div class="linuxcnc-source-row" data-linuxcnc-gcode-source="row">
<label>
LinuxCNC 5-axis source
<select data-action="select-linuxcnc-gcode-source" ${state.machineFileStaging.gcodeSources?.length ? "" : "disabled"}>
${renderLinuxCncGcodeSourceOptions(state)}
</select>
</label>
<button type="button" data-action="stage-linuxcnc-sources">Stage</button>
<span data-linuxcnc-gcode-source="status">${formatLinuxCncGcodeSourceStatus(state)}</span>
</div>
<ol class="gcode-list" start="${state.programStartLine}">${rows}</ol> <ol class="gcode-list" start="${state.programStartLine}">${rows}</ol>
<div class="gcode-progress"> <div class="gcode-progress">
<span>${state.activeLine} / ${programEndLine}</span> <span>${state.activeLine} / ${programEndLine}</span>
<div><i style="width: ${progress}%"></i></div> <div><i style="width: ${progress}%"></i></div>
</div> </div>
<section class="mdi-panel" data-mdi-mode="${state.machine.mode === "mdi"}">
<form class="mdi-command-row" data-action="mdi-form">
<strong>MDI</strong>
<input
type="text"
data-action="mdi-command"
value="${escapeHtml(state.machine.mdiCommand)}"
spellcheck="false"
autocomplete="off"
aria-label="MDI command"
/>
<button type="submit" data-action="mdi-submit">Run</button>
</form>
<div class="mdi-history">
${mdiQuickCommands(state).map((command) => `
<button type="button" data-action="mdi-history" data-command="${escapeHtml(command)}">${escapeHtml(command)}</button>
`).join("")}
</div>
</section>
`; `;
const form = element.querySelector('[data-action="mdi-form"]');
const input = element.querySelector('[data-action="mdi-command"]');
form.addEventListener("submit", (event) => {
event.preventDefault();
dispatch({ type: "RUN_MDI", command: input.value });
});
input.addEventListener("change", () => {
dispatch({ type: "SET_MDI_COMMAND", command: input.value });
});
for (const button of element.querySelectorAll('[data-action="mdi-history"]')) {
button.addEventListener("click", () => {
dispatch({ type: "RUN_MDI", command: button.dataset.command });
});
}
element.querySelector('[data-action="stage-linuxcnc-sources"]').addEventListener("click", () => {
dispatch({ type: "STAGE_MACHINE_FILES_REQUEST" });
});
const linuxCncSourceSelect = element.querySelector('[data-action="select-linuxcnc-gcode-source"]');
linuxCncSourceSelect.addEventListener("change", () => {
if (!linuxCncSourceSelect.value) return;
dispatch({ type: "LOAD_LINUXCNC_GCODE_SOURCE", sourceRel: linuxCncSourceSelect.value });
});
}
function renderLinuxCncGcodeSourceOptions(state) {
const sources = state.machineFileStaging.gcodeSources || [];
if (sources.length === 0) {
return `<option value="">stage machine files first</option>`;
}
const selected = state.machineFileStaging.selectedGcodeSourceRel || "";
return [
`<option value="">select source</option>`,
...sources.map((source) => `
<option value="${escapeHtml(source.sourceRel)}" ${source.sourceRel === selected ? "selected" : ""}>
${escapeHtml(source.filename)}
</option>
`),
].join("");
}
function formatLinuxCncGcodeSourceStatus(state) {
const sources = state.machineFileStaging.gcodeSources || [];
if (sources.length === 0) return `${state.machineFileStaging.status} / no staged LinuxCNC G-code sources`;
const selected = state.machineFileStaging.selectedGcodeSourceRel || "-";
return `${sources.length} staged / ${selected}`;
}
function mdiQuickCommands(state) {
const profileCommands = state.profile.kinematicsParameters.switchkinsTypes
.map((type) => type.mdiCommand)
.filter(Boolean);
return [
...(state.mdiHistory || []),
state.machine.mdiCommand,
"G0 X0 Y0 Z0",
"G91 X1",
"G90",
...profileCommands,
].filter((command, index, commands) => command && commands.indexOf(command) === index).slice(0, 7);
} }
function renderSidebar(element, state, dispatch) { function renderSidebar(element, state, dispatch) {
@@ -173,10 +279,10 @@ function renderSidebar(element, state, dispatch) {
<button type="button" class="sidebar-button" data-action="reset">RESET</button> <button type="button" class="sidebar-button" data-action="reset">RESET</button>
<button type="button" class="sidebar-button" data-action="mode-auto" data-active="${state.machine.mode === "auto"}">AUTO</button> <button type="button" class="sidebar-button" data-action="mode-auto" data-active="${state.machine.mode === "auto"}">AUTO</button>
<button type="button" class="sidebar-button" data-action="mode-manual" data-active="${state.machine.mode === "manual"}">MANUAL</button> <button type="button" class="sidebar-button" data-action="mode-manual" data-active="${state.machine.mode === "manual"}">MANUAL</button>
<button type="button" class="sidebar-button" data-action="mode-jog" data-active="${state.machine.mode === "jog"}">JOG</button> <button type="button" class="sidebar-button" data-action="mode-jog" data-active="${state.machine.mode === "manual"}">JOG</button>
<button type="button" class="sidebar-button" data-action="mode-mdi" data-active="${state.machine.mode === "mdi"}">MDI</button> <button type="button" class="sidebar-button" data-action="mode-mdi" data-active="${state.machine.mode === "mdi"}">MDI</button>
<button type="button" class="sidebar-button" data-action="kins-identity" data-active="${state.kinsType === "identity"}">IDENTITY</button> <button type="button" class="sidebar-button" data-action="kins-identity" data-active="${state.kinsType === "identity"}">IDENTITY</button>
<button type="button" class="sidebar-button" data-action="kins-tcp" data-active="${state.kinsType === "tcp-xyzac"}">TCP</button> <button type="button" class="sidebar-button" data-action="kins-tcp" data-active="${state.kinsType.startsWith("tcp-")}">TCP</button>
<time>13:30:31<br />20.06.2026</time> <time>13:30:31<br />20.06.2026</time>
`; `;
@@ -191,12 +297,14 @@ function renderSidebar(element, state, dispatch) {
dispatch({ type: "SET_KINS_TYPE", kinsType: "identity" }); dispatch({ type: "SET_KINS_TYPE", kinsType: "identity" });
}); });
element.querySelector('[data-action="kins-tcp"]').addEventListener("click", () => { element.querySelector('[data-action="kins-tcp"]').addEventListener("click", () => {
dispatch({ type: "SET_KINS_TYPE", kinsType: "tcp-xyzac" }); const tcpKinsType = state.profile.kinematicsParameters.switchkinsTypes.find((type) => type.value === 1)?.webKinsType || "tcp-xyzac";
dispatch({ type: "SET_KINS_TYPE", kinsType: tcpKinsType });
}); });
} }
function renderInfoTabs(element, state) { function renderInfoTabs(element, state) {
const frame = state.rtcpFrame; const frame = state.rtcpFrame;
const taskPolicy = state.linuxCncTaskPolicy;
element.innerHTML = ` element.innerHTML = `
<nav class="tabs"> <nav class="tabs">
<button type="button" class="active">Tool info and G-codes</button> <button type="button" class="active">Tool info and G-codes</button>
@@ -207,8 +315,21 @@ function renderInfoTabs(element, state) {
<dt>Size:</dt><dd>${state.fileSizeBytes} bytes</dd> <dt>Size:</dt><dd>${state.fileSizeBytes} bytes</dd>
<dt>Lines:</dt><dd>${state.lineCount} gcode lines</dd> <dt>Lines:</dt><dd>${state.lineCount} gcode lines</dd>
<dt>Machine:</dt><dd data-machine-state="summary">${state.machine.powerOn ? "power on" : "power off"} / ${state.machine.estopActive ? "estop" : "clear"} / ${state.machine.mode}</dd> <dt>Machine:</dt><dd data-machine-state="summary">${state.machine.powerOn ? "power on" : "power off"} / ${state.machine.estopActive ? "estop" : "clear"} / ${state.machine.mode}</dd>
<dt>Task policy:</dt><dd data-linuxcnc-task-policy="boundary">${taskPolicy.semanticBoundary}</dd>
<dt>Task state:</dt><dd data-linuxcnc-task-policy="state">${taskPolicy.taskState} / ${taskPolicy.taskMode} / ${taskPolicy.interpState}</dd>
<dt>Task gates:</dt><dd data-linuxcnc-task-policy="gates">${formatLinuxCncTaskGates(taskPolicy)}</dd>
<dt>Task source:</dt><dd data-linuxcnc-task-policy="source">${formatLinuxCncTaskSources(taskPolicy)}</dd>
<dt>Current line:</dt><dd data-program-current-line="${state.activeLine}">${state.activeLine}</dd> <dt>Current line:</dt><dd data-program-current-line="${state.activeLine}">${state.activeLine}</dd>
<dt>Program source:</dt><dd data-program-execution-source="${state.programExecutionSourceMode}">${state.programExecutionSourceMode}</dd>
<dt>Canonical:</dt><dd data-program-execution-summary="${state.programExecution?.summary?.motionEventCount ?? 0}">${state.programExecution?.summary?.motionEventCount ?? 0} motion / ${state.programExecution?.summary?.canonicalEventCount ?? 0} events</dd>
<dt>Switchkins:</dt><dd data-program-switchkins-summary="${state.programExecution?.summary?.switchkinsEventCount ?? 0}">${formatSwitchkinsSummary(state.programExecution)}</dd>
<dt>Machine run:</dt><dd data-machine-file-execution="status">${formatMachineFileExecution(state.machineFileExecution)}</dd>
<dt>Session:</dt><dd data-session-persistence="status">${state.sessionPersistence.status} / ${state.sessionPersistence.path ?? "-"}</dd>
<dt>Tool preview:</dt><dd data-tool-preview="detail">T${state.toolPreview.toolNumber} D${formatNumber(state.toolPreview.diameter, 2)} L${formatNumber(state.toolPreview.length, 3)} ${state.toolPreview.units}</dd> <dt>Tool preview:</dt><dd data-tool-preview="detail">T${state.toolPreview.toolNumber} D${formatNumber(state.toolPreview.diameter, 2)} L${formatNumber(state.toolPreview.length, 3)} ${state.toolPreview.units}</dd>
<dt>Program time:</dt><dd data-program-timing="summary">${formatProgramTiming(state)}</dd>
<dt>Segment time:</dt><dd data-program-timing="segment">${formatProgramTimingSegment(state)}</dd>
<dt>Runtime feedback:</dt><dd data-program-runtime-feedback="source">${formatProgramRuntimeFeedback(state)}</dd>
<dt>Runtime DTG:</dt><dd data-program-runtime-feedback="dtg">${formatProgramRuntimeDtg(state)}</dd>
<dt>Rapid distance:</dt><dd>37.634 mm</dd> <dt>Rapid distance:</dt><dd>37.634 mm</dd>
<dt>Feed distance:</dt><dd>5814.069 mm</dd> <dt>Feed distance:</dt><dd>5814.069 mm</dd>
<dt>X bounds:</dt><dd>8.000 to 113.000 = 105.000 mm</dd> <dt>X bounds:</dt><dd>8.000 to 113.000 = 105.000 mm</dd>
@@ -216,7 +337,19 @@ function renderInfoTabs(element, state) {
<dt>Z bounds:</dt><dd>-90.500 to -50.000 = 40.500 mm</dd> <dt>Z bounds:</dt><dd>-90.500 to -50.000 = 40.500 mm</dd>
<dt>RTCP frame:</dt><dd data-rtcp-diagnostic="frame">${frame.apiName} ${frame.rtcpState}</dd> <dt>RTCP frame:</dt><dd data-rtcp-diagnostic="frame">${frame.apiName} ${frame.rtcpState}</dd>
<dt>Boundary:</dt><dd data-rtcp-diagnostic="boundary">${frame.semanticBoundary}</dd> <dt>Boundary:</dt><dd data-rtcp-diagnostic="boundary">${frame.semanticBoundary}</dd>
<dt>INI:</dt><dd data-linuxcnc-ini="status">${state.iniConfigReadiness.loaded ? "loaded" : "pending"} / ${state.iniConfigReadiness.path ?? "-"}</dd>
<dt>INI kins:</dt><dd data-linuxcnc-ini="kins">${state.iniConfigReadiness.kinematics ?? "-"} / ${state.iniConfigReadiness.coordinates ?? "-"}</dd>
<dt>INI limits:</dt><dd data-linuxcnc-ini="limits">${formatAxisLimitSummary(state.profile.axisLimits)}</dd>
<dt>INI joints:</dt><dd data-linuxcnc-ini="joints">${state.iniConfigReadiness.jointCount ?? 0} joints / ${state.iniConfigReadiness.axisCount ?? 0} axes</dd>
<dt>Machine files:</dt><dd data-machine-file-staging="status">${formatMachineFileStaging(state.machineFileStaging)}</dd>
<dt>LinuxCNC G-code:</dt><dd data-linuxcnc-gcode-source="selected">${state.machineFileStaging.selectedGcodeSourceRel || state.programSourceRel || "-"}</dd>
<dt>Full boundary:</dt><dd data-full-execution-boundary="status">${formatFullExecutionBoundary(state.fullExecutionBoundary)}</dd>
<dt>Planner/task:</dt><dd data-full-execution-boundary="blockers">${formatFullExecutionBlockers(state.fullExecutionBoundary)}</dd>
<dt>Boundary evidence:</dt><dd data-full-execution-boundary="evidence">${formatFullExecutionEvidence(state.fullExecutionBoundary)}</dd>
<dt>LinuxCNC kins:</dt><dd data-rtcp-diagnostic="kinematics-ready">${frame.readiness.linuxCncKinematicsReady ? "ready" : "pending"}</dd> <dt>LinuxCNC kins:</dt><dd data-rtcp-diagnostic="kinematics-ready">${frame.readiness.linuxCncKinematicsReady ? "ready" : "pending"}</dd>
<dt>Kins context:</dt><dd data-rtcp-diagnostic="execution-context">${state.kinematicsExecutionContext}</dd>
<dt>Interpreter:</dt><dd data-linuxcnc-boundary="interpreter">${state.interpreterRuntimeReadiness?.loaded ? state.interpreterRuntimeReadiness.semanticBoundary : "pending"}</dd>
<dt>Interp context:</dt><dd data-linuxcnc-boundary="interpreter-context">${state.interpreterRuntimeReadiness?.executionContext ?? "none"}</dd>
<dt>Profile refs:</dt><dd>${state.profile.sourceReferences.length} source references</dd> <dt>Profile refs:</dt><dd>${state.profile.sourceReferences.length} source references</dd>
<dt>Adapter:</dt><dd data-linuxcnc-boundary="adapter">${state.linuxCncBoundaryAdapter.apiName}</dd> <dt>Adapter:</dt><dd data-linuxcnc-boundary="adapter">${state.linuxCncBoundaryAdapter.apiName}</dd>
<dt>Panel schema:</dt><dd data-linuxcnc-boundary="panel">${state.linuxCncBoundaryAdapter.panelSummary.schemaId} / ${state.linuxCncBoundaryAdapter.panelSummary.buttonCount} buttons</dd> <dt>Panel schema:</dt><dd data-linuxcnc-boundary="panel">${state.linuxCncBoundaryAdapter.panelSummary.schemaId} / ${state.linuxCncBoundaryAdapter.panelSummary.buttonCount} buttons</dd>
@@ -227,6 +360,97 @@ function renderInfoTabs(element, state) {
`; `;
} }
function formatLinuxCncTaskGates(taskPolicy) {
if (!taskPolicy) return "pending";
return [
taskPolicy.canJog ? "jog" : "jog blocked",
taskPolicy.canHome ? "home" : "home blocked",
taskPolicy.canRunAuto ? "auto" : "auto blocked",
taskPolicy.canExecuteMdi ? "mdi" : "mdi blocked",
taskPolicy.canPause ? "pause" : "pause blocked",
taskPolicy.canResume ? "resume" : "resume blocked",
].join(" / ");
}
function formatLinuxCncTaskSources(taskPolicy) {
if (!taskPolicy?.sourceReferences?.length) return "pending";
return taskPolicy.sourceReferences
.map((reference) => reference.path)
.join(" | ");
}
function formatProgramTiming(state) {
const timing = state.programExecutionTiming;
if (!timing) return "pending";
return `${formatDuration(state.programElapsedSeconds)} / ${formatDuration(timing.totalSeconds)} (${formatDuration(state.programRemainingSeconds)} left)`;
}
function formatProgramTimingSegment(state) {
const segment = state.programExecutionTiming?.segments?.[state.programExecutionMotionIndex || 0];
if (!segment) return "pending";
return `${segment.motionClass} line ${segment.line ?? "-"} ${formatNumber(segment.linearDistanceMm, 3)} mm ${formatDuration(segment.durationSeconds)} @ ${formatNumber(segment.velocityMmPerMin, 1)} mm/min`;
}
function formatProgramRuntimeFeedback(state) {
const feedback = state.programRuntimeFeedback;
if (!feedback) return "pending";
return `${feedback.sourceMode} sample ${feedback.sampleIndex ?? 0} line ${feedback.line ?? "-"} queue ${feedback.queueDepth ?? 0}/${feedback.activeDepth ?? 0} @ ${formatNumber(feedback.currentVelocityMmPerMin, 1)} mm/min`;
}
function formatProgramRuntimeDtg(state) {
const feedback = state.programRuntimeFeedback;
if (!feedback) return "pending";
const dtg = feedback.dtg || {};
return `DTG ${formatNumber(dtg.x, 3)} / ${formatNumber(dtg.y, 3)} / ${formatNumber(dtg.z, 3)} distance ${formatNumber(feedback.distanceToGo, 3)}`;
}
function formatDuration(seconds) {
const safeSeconds = Math.max(Number(seconds) || 0, 0);
const minutes = Math.floor(safeSeconds / 60);
const remainder = safeSeconds - minutes * 60;
return `${minutes}:${remainder.toFixed(1).padStart(4, "0")}`;
}
function formatSwitchkinsSummary(programExecution) {
const count = programExecution?.summary?.switchkinsEventCount ?? 0;
if (count === 0) return "0 events";
const codes = programExecution.summary.switchkinsCodes?.join("/") || "-";
return `${count} events ${codes}`;
}
function formatMachineFileStaging(machineFileStaging) {
if (!machineFileStaging || machineFileStaging.status === "not-staged") {
return "not staged";
}
if (machineFileStaging.status === "error") {
return `error ${machineFileStaging.lastError || "-"}`;
}
return `${machineFileStaging.status} ${machineFileStaging.fileCount || 0} files ${machineFileStaging.opfsRoot || "-"}`;
}
function formatMachineFileExecution(machineFileExecution) {
if (!machineFileExecution?.machineFilePlan) return "not run";
const summary = machineFileExecution.summary || {};
return `${summary.machineFileExecutionReady ? "ready" : "ran"} ${summary.motionEventCount || 0} motion ${machineFileExecution.machineFilePlan.profileId}`;
}
function formatFullExecutionBoundary(boundary) {
if (!boundary) return "pending";
const remap = boundary.machineFileBackedRemapReady ? "remap ready" : "remap pending";
const full = boundary.fullLinuxCncProgramExecutionReady ? "full ready" : "full blocked";
return `${boundary.phase} / ${remap} / ${full}`;
}
function formatFullExecutionBlockers(boundary) {
if (!boundary) return "pending";
return boundary.blockers.slice(0, 2).join("; ");
}
function formatFullExecutionEvidence(boundary) {
if (!boundary) return "pending";
return `${boundary.satisfied.length} satisfied / ${boundary.missing.length} missing / ${boundary.semanticBoundary}`;
}
function renderOverride(element, state, dispatch) { function renderOverride(element, state, dispatch) {
element.innerHTML = ` element.innerHTML = `
<section class="meter-card"> <section class="meter-card">
@@ -301,6 +525,7 @@ function renderBottomControls(element, state, dispatch) {
["Run", "RUN", () => dispatch({ type: "RUN" })], ["Run", "RUN", () => dispatch({ type: "RUN" })],
["Stop", "STOP", () => dispatch({ type: "STOP" })], ["Stop", "STOP", () => dispatch({ type: "STOP" })],
["Pause", "PAUSE", () => dispatch({ type: "PAUSE" })], ["Pause", "PAUSE", () => dispatch({ type: "PAUSE" })],
["Resume", "RESUME", () => dispatch({ type: "RESUME" })],
["Step", "STEP", () => dispatch({ type: "STEP" })], ["Step", "STEP", () => dispatch({ type: "STEP" })],
["Home", "HOME", () => dispatch({ type: "HOME" })], ["Home", "HOME", () => dispatch({ type: "HOME" })],
["X-", "JOG_X_NEG", () => dispatch({ type: "JOG", axis: "x", direction: -1 })], ["X-", "JOG_X_NEG", () => dispatch({ type: "JOG", axis: "x", direction: -1 })],
@@ -308,6 +533,9 @@ function renderBottomControls(element, state, dispatch) {
["Y-", "JOG_Y_NEG", () => dispatch({ type: "JOG", axis: "y", direction: -1 })], ["Y-", "JOG_Y_NEG", () => dispatch({ type: "JOG", axis: "y", direction: -1 })],
["Y+", "JOG_Y_POS", () => dispatch({ type: "JOG", axis: "y", direction: 1 })], ["Y+", "JOG_Y_POS", () => dispatch({ type: "JOG", axis: "y", direction: 1 })],
["MDI", "MDI_RUN", () => dispatch({ type: "RUN_MDI" })], ["MDI", "MDI_RUN", () => dispatch({ type: "RUN_MDI" })],
["Save Session", "SAVE_SESSION", () => dispatch({ type: "SAVE_SESSION_REQUEST" })],
["Restore Session", "RESTORE_SESSION", () => dispatch({ type: "RESTORE_SESSION_REQUEST" })],
["Audit", "AUDIT_FULL_BOUNDARY", () => dispatch({ type: "RUN_FULL_BOUNDARY_AUDIT_REQUEST" })],
["Full", "FULL", () => dispatch({ type: "TOGGLE_FULLSCREEN" })], ["Full", "FULL", () => dispatch({ type: "TOGGLE_FULLSCREEN" })],
]; ];
@@ -345,6 +573,12 @@ function formatNumber(value, digits = 3) {
return Number(value).toFixed(digits); return Number(value).toFixed(digits);
} }
function formatAxisLimitSummary(axisLimits) {
return Object.entries(axisLimits || {})
.map(([axis, limit]) => `${axis}[${formatNumber(limit.min, 0)},${formatNumber(limit.max, 0)}]`)
.join(" ");
}
function escapeHtml(value) { function escapeHtml(value) {
return String(value) return String(value)
.replaceAll("&", "&amp;") .replaceAll("&", "&amp;")

View File

@@ -3,24 +3,43 @@ import * as THREE from "../vendor/three/three.module.js";
const scenes = new WeakMap(); const scenes = new WeakMap();
export function renderFiveAxisScene(canvas, state) { export function renderFiveAxisScene(canvas, state) {
const preview = scenes.get(canvas) || createScene(canvas); let preview = scenes.get(canvas);
scenes.set(canvas, preview); if (!preview) {
preview = createPreview(canvas);
scenes.set(canvas, preview);
}
resizeRenderer(preview); if (preview.kind === "fallback") {
updateMachinePose(preview, state); renderFallbackPreview(preview, state);
preview.renderer.render(preview.scene, preview.camera); return;
}
try {
resizeRenderer(preview);
updateMachinePose(preview, state);
preview.renderer.render(preview.scene, preview.camera);
} catch (error) {
const fallback = createFallbackPreview(canvas, error);
scenes.set(canvas, fallback);
renderFallbackPreview(fallback, state);
return;
}
const pointCount = preview.pathLine.geometry.getAttribute("position").count; const pointCount = preview.pathLine.geometry.getAttribute("position").count;
canvas.dataset.threeReady = "true"; exposePreviewDataset(canvas, state, {
canvas.dataset.threeRevision = THREE.REVISION; pointCount,
canvas.dataset.threePathPoints = String(pointCount); sceneObjectCount: countSceneObjects(preview.scene),
canvas.dataset.threeSceneObjects = String(countSceneObjects(preview.scene)); toolhead: preview.toolGroup.position,
canvas.dataset.threeToolhead = JSON.stringify(toRoundedVector(preview.toolGroup.position)); renderer: "webgl",
canvas.dataset.threeToolAxis = JSON.stringify(toRoundedVector(state.toolAxisVector)); });
canvas.dataset.threeTcpPose = JSON.stringify(toRoundedPose(state.tcpPose)); }
canvas.dataset.threeRtcpState = state.rtcpState;
canvas.dataset.threeSelectedView = state.preview.selectedView; function createPreview(canvas) {
canvas.dataset.threeFrameApi = state.rtcpFrame.apiName; try {
return createScene(canvas);
} catch (error) {
return createFallbackPreview(canvas, error);
}
} }
function createScene(canvas) { function createScene(canvas) {
@@ -29,25 +48,36 @@ function createScene(canvas) {
antialias: true, antialias: true,
preserveDrawingBuffer: true, preserveDrawingBuffer: true,
}); });
renderer.setClearColor(0x050505, 1); renderer.setClearColor(0x07100d, 1);
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
const scene = new THREE.Scene(); const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(38, 1, 0.1, 100); scene.fog = new THREE.Fog(0x07100d, 7, 15);
camera.position.set(4.2, -6.4, 4.8); const camera = new THREE.PerspectiveCamera(42, 1, 0.1, 100);
camera.position.set(3.4, -5.0, 3.2);
camera.lookAt(0, 0, 0); camera.lookAt(0, 0, 0);
const ambient = new THREE.AmbientLight(0xffffff, 0.46); const ambient = new THREE.HemisphereLight(0xdffbff, 0x17110d, 0.86);
const key = new THREE.DirectionalLight(0xffffff, 1.1); const key = new THREE.DirectionalLight(0xffffff, 1.35);
key.position.set(3, -5, 7); key.position.set(3, -5, 7);
scene.add(ambient, key); const fill = new THREE.DirectionalLight(0x4fd5ff, 0.46);
fill.position.set(-4, 3, 3);
scene.add(ambient, key, fill);
const grid = new THREE.GridHelper(6.8, 12, 0x444444, 0x242424); const floor = new THREE.Mesh(
new THREE.BoxGeometry(6.2, 4.5, 0.08),
new THREE.MeshStandardMaterial({ color: 0x151b1f, roughness: 0.78, metalness: 0.12 }),
);
floor.position.z = -0.98;
scene.add(floor);
const grid = new THREE.GridHelper(6.2, 14, 0x4b555a, 0x252f32);
grid.rotation.x = Math.PI / 2; grid.rotation.x = Math.PI / 2;
grid.position.z = -0.93;
scene.add(grid); scene.add(grid);
const axes = new THREE.AxesHelper(1.25); const axes = new THREE.AxesHelper(1.45);
axes.position.set(-2.7, -2.25, -1.05); axes.position.set(-2.85, -2.0, -0.86);
scene.add(axes); scene.add(axes);
const envelope = buildEnvelope(); const envelope = buildEnvelope();
@@ -55,12 +85,12 @@ function createScene(canvas) {
const tableGroup = new THREE.Group(); const tableGroup = new THREE.Group();
const table = new THREE.Mesh( const table = new THREE.Mesh(
new THREE.BoxGeometry(2.55, 1.9, 0.16), new THREE.BoxGeometry(3.0, 2.15, 0.18),
new THREE.MeshStandardMaterial({ color: 0x353535, roughness: 0.72, metalness: 0.2 }), new THREE.MeshStandardMaterial({ color: 0x42484e, roughness: 0.68, metalness: 0.22 }),
); );
const platter = new THREE.Mesh( const platter = new THREE.Mesh(
new THREE.CylinderGeometry(0.72, 0.72, 0.13, 48), new THREE.CylinderGeometry(0.84, 0.84, 0.16, 64),
new THREE.MeshStandardMaterial({ color: 0x4f5960, roughness: 0.62, metalness: 0.35 }), new THREE.MeshStandardMaterial({ color: 0x6d7880, roughness: 0.48, metalness: 0.42 }),
); );
platter.rotation.x = Math.PI / 2; platter.rotation.x = Math.PI / 2;
platter.position.z = 0.14; platter.position.z = 0.14;
@@ -72,14 +102,14 @@ function createScene(canvas) {
const toolGroup = new THREE.Group(); const toolGroup = new THREE.Group();
const toolBody = new THREE.Mesh( const toolBody = new THREE.Mesh(
new THREE.CylinderGeometry(0.035, 0.055, 0.86, 24), new THREE.CylinderGeometry(0.045, 0.07, 1.05, 28),
new THREE.MeshStandardMaterial({ color: 0x26d7df, emissive: 0x073a3d, roughness: 0.32 }), new THREE.MeshStandardMaterial({ color: 0x29ecf0, emissive: 0x0a4f52, roughness: 0.28 }),
); );
toolBody.rotation.x = Math.PI / 2; toolBody.rotation.x = Math.PI / 2;
toolBody.position.z = 0.43; toolBody.position.z = 0.52;
const tcpPoint = new THREE.Mesh( const tcpPoint = new THREE.Mesh(
new THREE.SphereGeometry(0.075, 24, 16), new THREE.SphereGeometry(0.095, 28, 18),
new THREE.MeshStandardMaterial({ color: 0x1ffff4, emissive: 0x094f4f, roughness: 0.2 }), new THREE.MeshStandardMaterial({ color: 0x1ffff4, emissive: 0x0b6f6f, roughness: 0.18 }),
); );
const toolAxis = new THREE.Line( const toolAxis = new THREE.Line(
new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(), new THREE.Vector3(0, 0, 1)]), new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(), new THREE.Vector3(0, 0, 1)]),
@@ -89,6 +119,7 @@ function createScene(canvas) {
scene.add(toolGroup); scene.add(toolGroup);
const preview = { const preview = {
kind: "webgl",
renderer, renderer,
scene, scene,
camera, camera,
@@ -101,10 +132,136 @@ function createScene(canvas) {
return preview; return preview;
} }
function createFallbackPreview(canvas, error) {
return {
kind: "fallback",
canvas,
errorMessage: error instanceof Error ? error.message : String(error),
};
}
function renderFallbackPreview(preview, state) {
const { canvas } = preview;
const width = Math.max(canvas.clientWidth, 320);
const height = Math.max(canvas.clientHeight, 240);
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
}
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = "#07100d";
ctx.fillRect(0, 0, width, height);
const cx = width * 0.5;
const cy = height * 0.53;
const scale = Math.min(width / 7.2, height / 4.8);
drawFallbackGrid(ctx, cx, cy, scale);
ctx.strokeStyle = "#e43a35";
ctx.lineWidth = 1.5;
ctx.strokeRect(cx - 2.95 * scale, cy - 1.9 * scale, 5.9 * scale, 3.8 * scale);
ctx.fillStyle = "#42484e";
ctx.strokeStyle = "#79838a";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.roundRect(cx - 1.5 * scale, cy - 0.78 * scale, 3.0 * scale, 1.56 * scale, 4);
ctx.fill();
ctx.stroke();
ctx.fillStyle = "#6d7880";
ctx.strokeStyle = "#a3b0b8";
ctx.beginPath();
ctx.ellipse(cx, cy, 0.84 * scale, 0.48 * scale, 0, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
ctx.strokeStyle = "#ffffff";
ctx.lineWidth = 2;
ctx.beginPath();
for (let index = 0; index < 72; index += 1) {
const t = index / 71;
const x = cx + (-2.45 + t * 4.9) * scale;
const y = cy + Math.sin(t * Math.PI * 13) * 0.36 * scale;
if (index === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
const tcp = state.tcpPose;
const tool = state.toolAxisVector;
const toolX = cx + clamp(tcp.x * 0.035, -2.7, 2.7) * scale;
const toolY = cy - clamp(tcp.y * 0.035, -2.0, 2.0) * scale;
const axisX = toolX + tool.x * 0.85 * scale;
const axisY = toolY - (tool.y || 0.2) * 0.85 * scale;
ctx.strokeStyle = "#21f2f2";
ctx.fillStyle = "#1ffff4";
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(toolX, toolY);
ctx.lineTo(axisX, axisY);
ctx.stroke();
ctx.beginPath();
ctx.arc(toolX, toolY, 0.09 * scale, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "#b7c7b8";
ctx.font = "12px Courier New, monospace";
ctx.fillText("2D RTCP fallback", 12, height - 14);
exposePreviewDataset(canvas, state, {
pointCount: 72,
sceneObjectCount: 12,
toolhead: {
x: clamp(tcp.x * 0.035, -2.7, 2.7),
y: clamp(tcp.y * 0.035, -2.0, 2.0),
z: clamp(tcp.z * 0.04 + 0.35, -1.1, 1.9),
},
renderer: "2d-fallback",
});
canvas.dataset.threeFallbackReason = preview.errorMessage;
}
function drawFallbackGrid(ctx, cx, cy, scale) {
ctx.strokeStyle = "#273236";
ctx.lineWidth = 1;
for (let x = -3; x <= 3; x += 0.5) {
ctx.beginPath();
ctx.moveTo(cx + x * scale, cy - 2.1 * scale);
ctx.lineTo(cx + x * scale, cy + 2.1 * scale);
ctx.stroke();
}
for (let y = -2; y <= 2; y += 0.5) {
ctx.beginPath();
ctx.moveTo(cx - 3.1 * scale, cy + y * scale);
ctx.lineTo(cx + 3.1 * scale, cy + y * scale);
ctx.stroke();
}
}
function exposePreviewDataset(canvas, state, preview) {
canvas.dataset.threeReady = "true";
canvas.dataset.threeRevision = THREE.REVISION;
canvas.dataset.threePathPoints = String(preview.pointCount);
canvas.dataset.threeSceneObjects = String(preview.sceneObjectCount);
canvas.dataset.threeToolhead = JSON.stringify(toRoundedVector(preview.toolhead));
canvas.dataset.threeToolAxis = JSON.stringify(toRoundedVector(state.toolAxisVector));
canvas.dataset.threeTcpPose = JSON.stringify(toRoundedPose(state.tcpPose));
canvas.dataset.threeRtcpState = state.rtcpState;
canvas.dataset.threeSelectedView = state.preview.selectedView;
canvas.dataset.threeFrameApi = state.rtcpFrame.apiName;
canvas.dataset.threeRenderer = preview.renderer;
}
function buildEnvelope() { function buildEnvelope() {
const geometry = new THREE.BoxGeometry(5.8, 4.3, 2.6); const geometry = new THREE.BoxGeometry(5.9, 4.25, 2.7);
const edges = new THREE.EdgesGeometry(geometry); const edges = new THREE.EdgesGeometry(geometry);
const line = new THREE.LineSegments(edges, new THREE.LineBasicMaterial({ color: 0xcc2525 })); const line = new THREE.LineSegments(edges, new THREE.LineBasicMaterial({ color: 0xe43a35 }));
line.position.z = 0.2; line.position.z = 0.2;
return line; return line;
} }
@@ -113,13 +270,13 @@ function buildToolpath() {
const points = []; const points = [];
for (let index = 0; index < 72; index += 1) { for (let index = 0; index < 72; index += 1) {
const t = index / 71; const t = index / 71;
const x = -2.3 + t * 4.6; const x = -2.45 + t * 4.9;
const y = Math.sin(t * Math.PI * 13) * 0.28; const y = Math.sin(t * Math.PI * 13) * 0.36;
const z = -0.75 + Math.sin(t * Math.PI * 2) * 0.36; const z = -0.68 + Math.sin(t * Math.PI * 2) * 0.42;
points.push(new THREE.Vector3(x, y, z)); points.push(new THREE.Vector3(x, y, z));
} }
const geometry = new THREE.BufferGeometry().setFromPoints(points); const geometry = new THREE.BufferGeometry().setFromPoints(points);
return new THREE.Line(geometry, new THREE.LineBasicMaterial({ color: 0xf4f4f4 })); return new THREE.Line(geometry, new THREE.LineBasicMaterial({ color: 0xffffff }));
} }
function updateMachinePose(preview, state) { function updateMachinePose(preview, state) {
@@ -145,13 +302,13 @@ function updateMachinePose(preview, state) {
function setCameraView(camera, selectedView) { function setCameraView(camera, selectedView) {
if (selectedView === "x") { if (selectedView === "x") {
camera.position.set(6, 0.02, 0.4); camera.position.set(6, 0.02, 0.45);
} else if (selectedView === "y") { } else if (selectedView === "y") {
camera.position.set(0.02, -6, 0.6); camera.position.set(0.02, -6, 0.7);
} else if (selectedView === "z") { } else if (selectedView === "z") {
camera.position.set(0.01, -0.02, 7); camera.position.set(0.01, -0.02, 6.6);
} else { } else {
camera.position.set(4.2, -6.4, 4.8); camera.position.set(3.4, -5.0, 3.2);
} }
camera.lookAt(0, 0, 0); camera.lookAt(0, 0, 0);
camera.updateProjectionMatrix(); camera.updateProjectionMatrix();

View File

@@ -333,25 +333,285 @@ promotionAllowed=false
## 9. 当前状态 ## 9. 当前状态
```text ```text
status=M6_linuxcnc_kinematics_frame_proof_complete status=M17_linuxcnc_5axis_gcode_source_ingest_complete
active_style=gmoccapy_5_axis active_style=gmoccapy_5_axis
frontend_framework=none frontend_framework=none
ui_stack=html_css_typescript_es_modules ui_stack=html_css_typescript_es_modules
preview_stack=threejs preview_stack=threejs
semantic_boundary=linuxcnc_owned semantic_boundary=linuxcnc_owned
latest_batch=M6-linuxcnc-kinematics-frame-proof latest_batch=M17-linuxcnc-5axis-gcode-source-ingest
latest_gate=linuxcnc_kinematics_runtime_smoke=ok,profile_boundary_smoke=ok,rtcp_store_smoke=ok,gmoccapy_shell_smoke=ok,gmoccapy_static_build=ok latest_gate=full_execution_boundary_smoke=ok,linuxcnc_kinematics_runtime_smoke=ok,linuxcnc_interpreter_runtime_smoke=ok,linuxcnc_ini_runtime_smoke=ok,full_linuxcnc_5axis_source_node_smoke=ok,machine_file_staging_smoke=ok,five_axis_session_smoke=ok,profile_boundary_smoke=ok,rtcp_store_smoke=ok,gmoccapy_shell_smoke=ok,gmoccapy_dist_smoke=ok,gmoccapy_static_build=ok
rtcp_ui_state=implemented_fixture_fallback_and_node_kinematics_wasm_frame rtcp_ui_state=implemented_browser_worker_and_node_kinematics_wasm_frame_with_fixture_fallback
control_wiring=power_estop_reset_auto_manual_jog_mdi_run_stop_pause_step_overrides_coolant_spindle_preview_home_reload_full control_wiring=power_estop_reset_auto_manual_jog_mdi_run_stop_pause_step_overrides_coolant_spindle_preview_home_reload_full
gcode_loading=implemented_browser_file_text_staging gcode_loading=implemented_browser_file_text_staging_with_linuxcnc_interpreter_execution
program_current_line=implemented_fixture_line_playback_and_highlight linuxcnc_5axis_gcode_sources=implemented_and_guarded_to_linuxcnc_source_manifest_trt_demos_only
program_current_line=implemented_linuxcnc_canonical_motion_highlight_with_fixture_fallback
tool_preview=implemented_tool_card_and_threejs_marker tool_preview=implemented_tool_card_and_threejs_marker
threejs_preview=implemented_basic_canvas_scene threejs_preview=implemented_basic_canvas_scene
profile_source_map=implemented_xyzac_trt profile_source_map=implemented_xyzac_trt_and_xyzbc_trt
profile_switching=implemented_xyzac_trt_and_xyzbc_trt_with_runtime_reload
pyvcp_hal_schema=implemented_xyzac_trt_switchkins pyvcp_hal_schema=implemented_xyzac_trt_switchkins
linuxcnc_boundary_adapter=kinematics_runtime_connected_node_interpreter_remap_missing linuxcnc_boundary_adapter=kinematics_and_interpreter_runtime_connected_remap_planner_missing
linuxcnc_kinematics_wasm=node_proof_ready_xyzac_trt full_execution_boundary=implemented_machine_file_remap_ready_with_planner_task_hal_blockers
browser_kinematics_wasm=not_connected_fixture_fallback linuxcnc_kinematics_wasm=browser_and_node_proof_ready_xyzac_trt
full_program_execution=not_promoted_fixture_line_playback browser_kinematics_wasm=worker_connected_source_and_dist_xyzac_trt
next_batch=browser_kinematics_wasm_asset_worker_or_interpreter_execution_source linuxcnc_interpreter_wasm=browser_worker_and_node_direct_canonical_program_execution_ready_with_switchkins_mcode_preservation
browser_interpreter_wasm=worker_connected_source_and_dist
switchkins_rtcp_program_execution=implemented_m428_m429_program_events_drive_rtcp_and_kinematics_switch
full_program_execution=partial_interpreter_canonical_and_switchkins_event_ready_remap_planner_not_promoted
session_persistence=implemented_opfs_save_restore_for_5axis_session
machine_file_staging=implemented_linuxcnc_trt_ini_hal_tool_table_remap_demo_opfs_staging
machine_file_backed_run=implemented_linuxcnc_fiveaxis_remap_wasm_machine_file_execution
planner_task_hal_gap=audited_as_blocked_without_native_task_nml_realtime_hal_and_planner_queue_runtime
next_batch=trajectory_planner_wasm_boundary_or_native_task_hal_port
```
## 10. M12 任务
```text
M12-switchkins-rtcp-program-execution
```
状态:
```text
completed_with_program_level_rtcp_switching
```
已完成:
- `linuxcnc-interpreter-runtime` 识别 LinuxCNC TRT 配置中的 `M428/M429/M430` switchkins remap M-code
- 因当前 interpreter WASM 未启用 Python/NGC remap runtime运行前会从送入 WASM 的程序文本中移除这些高位 M-code避免 `M-code greater than 199` 阻断普通 canonical motion
- 原始程序的 `M428/M429/M430` 被保留为 `switchkinsEvents`,并打上 `linuxcnc_switchkins_remap_mcode_preserved_web_runtime_applied` 边界;
- canonical motion event 继承最近的 switchkins 状态,`RUN/STEP/RUN_FRAME` 按程序自动切换 `identity` / `tcp-xyzac` / `tcp-xyzbc`
- node/browser kinematics runtime 增加 `switchKinematics()`,程序内 `M428/M429` 会同步切换 LinuxCNC kinematics WASM 的 `lckins_switch()`
- gmoccapy diagnostics 显示 program switchkins event count
- smoke 覆盖含 `M428/M429` 的五轴联动 RTCP 程序:加载后进入 TCP执行 A/C 联动段保持 RTCP on运行到 `M429` 后回到 identity。
边界说明:
```text
switchkinsProgramBoundary=linuxcnc_remap_mcode_preserved_and_web_runtime_applied
interpreterBoundary=linuxcnc_interpreter_wasm_canonical_events
kinematicsBoundary=linuxcnc_kinematics_wasm_c_abi
remapRuntimeReady=false
plannerRuntimeReady=false
fullLinuxCncProgramExecutionReady=false
```
## 14. M16 任务
```text
M16-linuxcnc-tp-queue-timing-runtime
```
状态:
```text
completed_with_tp_wasm_and_web_node_smoke
```
已完成:
- `wasm-port/runtime/core/linuxcnc_wrap/linuxcnc_tp_wasm.c` 新增
`lctp_run_canonical_motion_timing()`
- TP WASM C ABI 将 LinuxCNC interpreter canonical motion events 入队到 vendored
LinuxCNC `src/emc/tp` runtime并通过 `tpRunCycle()` 生成 segment timing
- canonical 零长度 motion 保留为 0 秒 timing segment不作为 TP enqueue failure
- 新增 `wasm-port/runtime/sdk/src/linuxcnc-tp.js`
- `linuxcnc-interpreter-runtime``runProgram()` / `runMachineFileProgram()` 后调用
TP SDK`programExecution.plannerTiming` 使用
`linuxcnc_tp_queue_runtime_timing_from_canonical_motion` 边界;
- store 优先使用 TP planner timingJS `execution-timing.js` 保留为 fallback/MDI
lightweight path
- full execution boundary 可在 TP timing 成功时报告 `plannerRuntimeReady=true`
- browser source/dist smoke 验证 interpreter Worker readiness 包含 TP planner runtime
并验证含 `ARC_FEED` 的真实 G-code 程序通过 TP arc timing segment。
边界说明:
```text
timingBoundary=linuxcnc_tp_queue_runtime_timing_from_canonical_motion
plannerRuntimeReady=true for interpreter canonical motion with TP WASM timing
nativeTaskReady=false
nativeHalSyncReady=false
fullLinuxCncProgramExecutionReady=false
hardwareDrive=false
```
验证:
```text
source /home/cnc/emsdk/emsdk_env.sh >/dev/null && bash wasm-port/tools/build_tp_wasm.sh && bash wasm-port/tests/wasm/node/verify_tp_wasm.sh
node web-rtcp-5axis-sim-plan/tests/node/verify_linuxcnc_interpreter_runtime.mjs
npm --prefix web-rtcp-5axis-sim-plan/app run smoke:node
npm --prefix web-rtcp-5axis-sim-plan/app run smoke
```
## 11. M13 任务
```text
M13-interpreter-worker-runtime
```
状态:
```text
completed_with_browser_worker_smoke
```
已完成:
- 新增 `linuxcnc-interpreter-worker.js``linuxcnc-interpreter-worker-client.js`
- 浏览器默认优先通过 Web Worker 加载 LinuxCNC interpreter WASM
- store 的 `RUN_INTERPRETER_PROGRAM` 支持 async runtime避免 worker 结果阻塞 UI 线程;
- 增加 `interpreterExecutionPending` 和 sequence guard防止旧解释器结果覆盖新加载程序
- gmoccapy diagnostics 显示 interpreter execution context
- browser smoke 验证 kinematics 和 interpreter 都在 Worker并验证 source/dist 两条路径。
边界说明:
```text
browserInterpreterExecutionContext=worker
nodeInterpreterExecutionContext=direct
interpreterBoundary=linuxcnc_interpreter_wasm_canonical_events
remapRuntimeReady=false
plannerRuntimeReady=false
fullLinuxCncProgramExecutionReady=false
```
## 14. M16 任务
```text
M16-planner-task-hal-boundary-audit
```
状态:
```text
completed_with_full_execution_boundary_smoke
```
已完成:
- 新增 `full-execution-boundary.js`,统一汇总 kinematics、interpreter canonical、machine-file staging、five-axis remap run、switchkins HAL evidence
- store 新增 `fullExecutionBoundary` 派生状态和 `RUN_FULL_BOUNDARY_AUDIT_REQUEST`
- gmoccapy diagnostics 显示 full boundary、planner/task/HAL blockers 和证据摘要;
- 底部新增 `Audit` 按钮,可触发 machine-file staging + machine-file backed five-axis remap run
- interpreter runtime 对 machine-file backed run 的 `remapRuntimeReady` 改为基于 `fiveaxis_ini_open=1``fiveaxis_remaps_ready=1``fiveaxis_file_reached_exit=1`
- Node/browser smoke 验证 partial remap boundary ready同时继续禁止 full LinuxCNC program execution promoted。
边界说明:
```text
fullExecutionBoundary=linuxcnc_machine_file_remap_ready_planner_task_hal_blocked
remapRuntimeReady=true for staged fiveAxisRemap C ABI run
halSwitchkinsEvidenceReady=true for fiveaxis_hal_switchkins evidence
nativeTaskReady=false
nativeHalSyncReady=false
plannerRuntimeReady=false
fullLinuxCncProgramExecutionReady=false
promotionAllowed=false
```
## 15. M17 任务
```text
M17-linuxcnc-5axis-gcode-source-ingest
```
状态:
```text
completed_with_real_linuxcnc_5axis_source_program_smoke
```
已完成:
- machine-file staging 从 LinuxCNC source manifest 中追加 TRT `demos/*.ngc`,包括 `boat-xyzac.ngc``boat-xyzbc.ngc``impeller-7bl-xyzac.ngc``xyzac_switchkins*.ngc``xyzbc_switchkins.ngc`
- `selectMachineFileProgram()` 强制 5 轴 G-code 必须来自 LinuxCNC 源程序目录 `configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/*.ngc`
- 新增 `listLinuxCncGcodeSources()``selectMachineFileProgram()`
- store 新增 `LOAD_LINUXCNC_GCODE_SOURCE`,可把 staged LinuxCNC 真实 5 轴 `.ngc` 源文件加载到 G-code 面板;
- gmoccapy G-code 面板新增 LinuxCNC 5-axis source 下拉框和 staging 按钮;
- machine-file backed remap run 使用当前选中的 LinuxCNC `.ngc` 源程序路径;
- Node/browser smoke 验证选择 `impeller-7bl-xyzac.ngc`UI 加载真实源程序machine-file run 的 `wasmProgramPath` 指向该源文件。
边界说明:
```text
gcodeSourceBoundary=linuxcnc_vendored_5axis_gcode_source_file
sourceMode=linuxcnc-vendored-5axis-gcode
machineFileBackedRunProgram=selected_staged_linuxcnc_ngc_source
sourceDirectoryGuard=configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/*.ngc only
remapRuntimeReady=true for staged fiveAxisRemap C ABI run
plannerRuntimeReady=false
nativeTaskReady=false
nativeHalSyncReady=false
fullLinuxCncProgramExecutionReady=false
```
## 12. M14 任务
```text
M14-opfs-machine-file-staging
```
状态:
```text
completed_with_node_and_browser_opfs_smoke
```
已完成:
- 新增 `linuxcnc-machine-file-staging.js`
- 复用 `wasm-port/runtime/sdk/src/sim-config-staging.js``planSimConfigStaging()`,为 `xyzac-trt` / `xyzbc-trt` 生成 LinuxCNC sim-config staging plan
- 将 INI、tool table、HAL、PyVCP XML、`remap_subs/*.ngc`、demo G-code 等 vendored LinuxCNC 文本文件保存到 OPFS 风格路径;
- store 增加 `machineFileStaging` 状态和 `stageMachineFiles()` API
- gmoccapy diagnostics 显示 machine-file staging 状态;
- build 产物复制 `wasm-port/tools/source-manifest.txt`,保证 dist 页面也能生成 staging plan
- node smoke 使用 memory OPFS 验证文件保存browser smoke 使用真实 OPFS 验证 source/dist staging。
边界说明:
```text
machineFileStagingBoundary=linuxcnc_sim_config_file_staging_plan_plus_opfs_text_persistence
stagedFiles=ini,hal,pyvcp,tool_table,remap_ngc,demo_gcode
nativeHalTaskSync=false
remapRuntimeReady=false
plannerRuntimeReady=false
fullLinuxCncProgramExecutionReady=false
```
## 13. M15 任务
```text
M15-machine-file-backed-fiveaxis-remap-run
```
状态:
```text
completed_with_node_and_browser_fiveaxis_remap_run
```
已完成:
- `linuxcnc-interpreter-runtime` 新增 `runMachineFileProgram()`
- interpreter Worker 支持 `runMachineFileProgram` 转发;
- store 新增 `RUN_MACHINE_FILE_PROGRAM` action 和 `machineFileExecution` 状态;
- 使用 M14 的 machine-file staging plan/files把 TRT INI、tool table、remap NGC、demo G-code 写入 Emscripten FS
- 调用 LinuxCNC interpreter SDK `runSimConfigProgram({ executionMode: "fiveAxisRemap" })`
- Node/browser smoke 验证 `fiveaxis_ini_open=1``fiveaxis_remaps_ready=1``fiveaxis_file_reached_exit=1`
- gmoccapy diagnostics 显示 machine-file backed run 状态。
边界说明:
```text
machineFileBackedRunBoundary=linuxcnc_fiveaxis_remap_wasm_machine_file_execution
sourceMode=linuxcnc-machine-file-remap-wasm
remapRuntimeReady=vendored_linuxcnc_fiveaxis_remap_c_abi_for_staged_files
nativeTaskHalSync=false
plannerRuntimeReady=false
fullLinuxCncProgramExecutionReady=false
``` ```

View File

@@ -173,7 +173,20 @@ LinuxCNC kinematics proof:
promotionAllowed=true for kinematics frame source only promotionAllowed=true for kinematics frame source only
``` ```
这表示 Web 仿真已经具备 RTCP 状态链路、TCP pose 显示、刀轴向量显示和控制按钮切换Node proof 路径已通过 `createLinuxCncKinematicsSdk({ moduleId: "xyzac-trt" })` 加载 LinuxCNC kinematics WASM 并生成 frame浏览器 smoke 仍保留 fixture fallback不把 fallback 冒充 LinuxCNC runtime proof。 这表示 Web 仿真已经具备 RTCP 状态链路、TCP pose 显示、刀轴向量显示和控制按钮切换Node 和浏览器路径已通过 `createLinuxCncKinematicsSdk({ moduleId: "xyzac-trt" })` 加载 LinuxCNC kinematics WASM 并生成 frame浏览器默认使用 Worker 隔离 kinematics WASM 调用。fixture fallback 仍保留为 runtime load failure 的 UI 安全路径,但 smoke 不再把 fixture fallback 当作当前 proof。
当前普通 G-code 程序执行也已接入 `createLinuxCncInterpSdk()`
```text
sourceMode=linuxcnc-interpreter-wasm
semanticBoundary=linuxcnc_interpreter_wasm_canonical_events
RUN/STEP source=LinuxCNC canonical motion events
remapRuntimeReady=false
plannerRuntimeReady=false
fullLinuxCncProgramExecutionReady=false
```
这只提升普通 G-code canonical execution source不代表 Python remap、tool DB、external user-M process 或完整 planner 已 promoted。
### Step 3gmoccapy UI 组件 ### Step 3gmoccapy UI 组件
@@ -432,6 +445,55 @@ readiness
- Browser canvas nonblank smoke - Browser canvas nonblank smoke
- 文档 traceability 检查。 - 文档 traceability 检查。
### Step 9LinuxCNC TP queue timing runtime
目标:
- 删除真实 G-code 路径上的
`linuxcnc_canonical_motion_timing_estimate_not_planner_queue` 边界;
- 将 interpreter 生成的 LinuxCNC canonical motion events 输入从
`linuxcnc/src/emc/tp` 移植构建出的 WASM runtime
-`tpCreate()``tpSetCycleTime()``tpSetVmax()``tpSetVlimit()`
`tpSetAmax()``tpSetTermCond()``tpAddLine()``tpRunCycle()`
`tpGetPos()` 生成程序级 queue timing
- UI 的 elapsed/remaining/current velocity 来自 TP queue runtime 输出,而不是
JavaScript 按距离和进给率估算。
实现步骤:
1.`wasm-port/runtime/core/linuxcnc_wrap/linuxcnc_tp_wasm.c` 新增
`lctp_run_canonical_motion_timing()` C ABI。
2. 输入只接受 interpreter runtime 已解析出的 canonical motion JSONC 侧只做字段读取、
TP enqueue 和 cycle loop不解释 G-code。
3.`wasm-port/runtime/sdk/src/linuxcnc-tp.js` 新增 TP SDK负责加载
`build/wasm/tp/linuxcnc_tp.{js,wasm}` 并调用 C ABI。
4. 在 Web interpreter runtime 中可选接入 `createLinuxCncTpSdk()``runProgram()`
`runMachineFileProgram()` 完成 canonical motion 后立即跑 TP queue timing。
5. `programExecution.summary.plannerRuntimeReady=true` 只在 TP runtime 调用成功且
motionCount 一致时成立;否则保留 canonical execution但不能把 planner timing 标为 ready。
6. `web-rtcp-5axis-full-linuxcnc-execution-boundary` 可把 `plannerRuntimeReady=true` 作为
已满足项,但仍必须显示 `nativeTaskReady=false``nativeHalSyncReady=false`
`fullLinuxCncProgramExecutionReady=false`,因为 native task/NML、HAL realtime thread 和
硬件驱动没有接入。
当前边界:
```text
sourceMode=linuxcnc-interpreter-wasm
timing.semanticBoundary=linuxcnc_tp_queue_runtime_timing_from_canonical_motion
plannerRuntimeReady=true
nativeTaskReady=false
nativeHalSyncReady=false
fullLinuxCncProgramExecutionReady=false
```
不允许:
- 用 JS 重写 lookahead、blend、exact stop、S-curve 或 G-code modal 语义;
- 把 TP queue timing 说成已经驱动硬件;
- 把 native LinuxCNC task/NML/realtime HAL 说成已经完成;
- 对没有通过 TP runtime 的 operator MDI lightweight path 标记 planner ready。
## 5. 禁止事项 ## 5. 禁止事项
- 不在 JavaScript 中实现 G-code 解释器。 - 不在 JavaScript 中实现 G-code 解释器。
@@ -439,7 +501,11 @@ readiness
- 不把 gmoccapy Python/GTK runtime 移植进浏览器。 - 不把 gmoccapy Python/GTK runtime 移植进浏览器。
- 不把 Python remap/tool DB/external user-M process 伪装成已支持。 - 不把 Python remap/tool DB/external user-M process 伪装成已支持。
- 不用 UI fixture 结果冒充 LinuxCNC runtime proof。 - 不用 UI fixture 结果冒充 LinuxCNC runtime proof。
- 用于 5 轴 machine-file backed run 的 G-code 必须来自 LinuxCNC 源程序目录
`configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/*.ngc`
用户上传或手写的 G-code 只能作为 operator file/普通 interpreter 输入,不得标记为
`linuxcnc-vendored-5axis-gcode` 或用于 5 轴源程序边界证明。
## 6. 开工建议 ## 6. 开工建议
当前已完成 Step 1 到 Step 7 的 Node LinuxCNC kinematics proof。下一轮应把 browser asset copy/worker 接入完成,让真实浏览器也能加载 kinematics WASM或继续推进 interpreter/remap/planner使 program execution 从 fixture line playback 升级 当前已完成 Step 1 到 Step 9 的 Node/browser LinuxCNC kinematics proof、浏览器 Worker kinematics 隔离、浏览器 Worker interpreter canonical execution source、`xyzac-trt`/`xyzbc-trt` profile 切换、OPFS 五轴会话保存/恢复、OPFS machine-file staging、machine-file backed `fiveAxisRemap` C ABI run、程序级 `M428/M429` switchkins RTCP 自动切换、LinuxCNC TP queue timing runtime、full execution boundary audit以及真实 LinuxCNC TRT 5 轴 `.ngc` 源程序 staging/选择/运行路径。`M428/M429/M430` 当前既可作为 Web runtime switchkins 事件驱动 `lckins_switch()`,也可在 staged machine-file run 中交给 vendored LinuxCNC five-axis remap C ABI 验证;只有 LinuxCNC source manifest 中的 `configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/*.ngc` 可作为 `linuxcnc-vendored-5axis-gcode` 进入 UI 和 machine-file run。`web-rtcp-5axis-full-linuxcnc-execution-boundary` 可以在真实 interpreter canonical motion 已通过 TP WASM 时报告 `plannerRuntimeReady=true`,但必须继续把 `nativeTaskReady=false``nativeHalSyncReady=false``fullLinuxCncProgramExecutionReady=false``promotionAllowed=false` 显示为 blocker除非后续真正接入 native task/NML 和 realtime HAL sync

View File

@@ -519,6 +519,7 @@ LinuxCNC G-code text
8. `M7-browser-kinematics-runtime`:浏览器 asset copy/worker 加载 kinematics WASM。 8. `M7-browser-kinematics-runtime`:浏览器 asset copy/worker 加载 kinematics WASM。
9. `M8-session`OPFS 保存/恢复五轴仿真会话。 9. `M8-session`OPFS 保存/恢复五轴仿真会话。
10. `M9-release-gate`Node/browser/docs/release gate。 10. `M9-release-gate`Node/browser/docs/release gate。
11. `M10-profile-switching``xyzac-trt` / `xyzbc-trt` profile 切换并重载对应 kinematics WASM。
## 9. 验收标准 ## 9. 验收标准
@@ -537,15 +538,10 @@ LinuxCNC G-code text
## 10. 下一步动作 ## 10. 下一步动作
当前已完成到 `M6-kinematics-frame-proof`。建议下一轮优先做: 当前已完成到 `M11-profile-session-business-closure`。建议下一轮优先做:
- 在 build-static 中复制 `wasm-port/build/wasm/kinematics` 所需产物,或新增 worker 隔离 kinematics WASM 加载 - 把 interpreter WASM 调用迁移到 Worker保持 UI 主线程只做渲染和状态编排
- `app/src/main.js` 在浏览器中 attach `xyzac-trt` kinematics runtime - 接入 OPFS machine-session 的 INI/HAL/tool table staging让 interpreter execution 使用真实会话文件
- 保持 fixture fallback不把 browser fixture smoke 标记为 LinuxCNC proof - 继续推进 remap/planner 边界,不把普通 canonical execution 冒充完整 LinuxCNC task/motion runtime
- 或转向 LinuxCNC interpreter/remap/planner 接入,把 program execution 从 fixture line playback 升级 - 保持 source/browser/dist 三层 smokeinterpreter canonical frame、kinematics worker frame、DRO/preview 同步
- 使用 Vite + Three.js
- 先接入现有普通 G-code program inventory
- 画出五轴机床基本结构;
- 按 AXIS/PyVCP/gmoccapy 参考放置 toolbar、Manual/MDI、DRO、SWITCHKINS、joint values、preview
- 加 Playwright smoke
- 启动本地 dev server 给出访问地址。 - 启动本地 dev server 给出访问地址。

View File

@@ -24,22 +24,26 @@ CNC 语义必须来自 LinuxCNC source/WASM/source-derived boundary
| 右侧模式按钮栏 | `app/src/ui/gmoccapy-status-sidebar.ts` | gmoccapy screenshot | UI action dispatch | browser button/action smoke | | 右侧模式按钮栏 | `app/src/ui/gmoccapy-status-sidebar.ts` | gmoccapy screenshot | UI action dispatch | browser button/action smoke |
| 底部运行控制 | `app/src/ui/gmoccapy-bottom-controls.ts` | gmoccapy/AXIS run controls | UI action dispatch | playback smoke | | 底部运行控制 | `app/src/ui/gmoccapy-bottom-controls.ts` | gmoccapy/AXIS run controls | UI action dispatch | playback smoke |
| G-code 当前行 | `app/src/ui/gmoccapy-gcode-panel.ts` | AXIS/gmoccapy program display | LinuxCNC output rendering | active-line smoke | | G-code 当前行 | `app/src/ui/gmoccapy-gcode-panel.ts` | AXIS/gmoccapy program display | LinuxCNC output rendering | active-line smoke |
| 上电/急停/复位/模式操作 | `app/src/state/store.js`, `app/src/ui/gmoccapy-shell.js` | gmoccapy/AXIS operator workflow | browser UI runtime fixture | node smoke + browser operator smoke | | 上电/急停/复位/模式操作 | `app/src/state/store.js`, `app/src/state/linuxcnc-task-policy.js`, `app/src/ui/gmoccapy-shell.js` | `src/emc/nml_intf/emc.hh`, `src/emc/task/emctaskmain.cc`, `src/emc/task/emctask.cc` | LinuxCNC task source-referenced Web policy | node smoke + browser operator smoke |
| JOG/MDI 操作 | `app/src/state/store.js`, `app/src/ui/gmoccapy-shell.js` | AXIS Manual/MDI workflow | browser UI runtime fixture | node smoke + browser operator smoke | | JOG/MDI 操作 | `app/src/state/store.js`, `app/src/state/linuxcnc-task-policy.js`, `app/src/ui/gmoccapy-shell.js` | `src/emc/task/emctaskmain.cc` `EMC_JOG_INCR`, `EMC_JOINT_HOME`, `EMC_TASK_PLAN_EXECUTE` | LinuxCNC task source-referenced Web policy | node smoke + browser operator smoke |
| G-code 文件加载 | `app/src/ui/gmoccapy-shell.js`, `app/src/state/store.js` | AXIS/gmoccapy open program workflow | file text staging only, not LinuxCNC interpreter proof | node smoke + browser operator smoke | | 程序运行/暂停/继续/停止 | `app/src/state/store.js`, `app/src/state/linuxcnc-task-policy.js`, `app/src/ui/gmoccapy-shell.js` | `src/emc/task/emctaskmain.cc` `EMC_TASK_PLAN_RUN`, `EMC_TASK_PLAN_PAUSE`, `EMC_TASK_PLAN_RESUME`, `EMC_TASK_ABORT` | LinuxCNC task source-referenced Web policy | node smoke + browser operator smoke |
| 程序执行当前行显示 | `app/src/state/store.js`, `app/src/ui/gmoccapy-shell.js` | AXIS/gmoccapy current line display | fixture line playback until LinuxCNC interpreter is connected | node smoke + browser operator smoke | | G-code 文件加载 | `app/src/ui/gmoccapy-shell.js`, `app/src/state/store.js` | AXIS/gmoccapy open program workflow | browser file staging + LinuxCNC interpreter execution | node smoke + browser operator smoke |
| LinuxCNC 5 轴源程序选择 | `app/src/runtime/linuxcnc-machine-file-staging.js`, `app/src/state/store.js`, `app/src/ui/gmoccapy-shell.js` | `configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/*.ngc` | guarded vendored LinuxCNC source-directory G-code staging + selected machine-file run | machine-file staging node smoke + browser source selector smoke |
| 程序执行当前行显示 | `app/src/state/store.js`, `app/src/ui/gmoccapy-shell.js` | AXIS/gmoccapy current line display | LinuxCNC interpreter canonical motion events with fixture fallback | node smoke + browser operator smoke |
| 程序执行速度/时间 | `wasm-port/runtime/core/linuxcnc_wrap/linuxcnc_tp_wasm.c`, `wasm-port/runtime/sdk/src/linuxcnc-tp.js`, `app/src/runtime/linuxcnc-interpreter-runtime.js`, `app/src/state/store.js`, `app/src/ui/gmoccapy-shell.js` | LinuxCNC interpreter canonical motion events queued through `src/emc/tp/tp.c`, `tc.c`, `tcq.c`, S-curve/Ruckig support sources | LinuxCNC TP queue runtime timing from canonical motion; JS estimate kept only as fallback/MDI lightweight path | TP WASM smoke + interpreter/store node smoke + browser source/dist TP timing smoke |
| 刀具预览 | `app/src/ui/gmoccapy-shell.js`, `app/src/visualization/five-axis-scene.js` | gmoccapy/vismach tool display | visualization/runtime state display | browser operator smoke | | 刀具预览 | `app/src/ui/gmoccapy-shell.js`, `app/src/visualization/five-axis-scene.js` | gmoccapy/vismach tool display | visualization/runtime state display | browser operator smoke |
| 3D 五轴预览 | `app/src/visualization/five-axis-scene.js` | `qtvismach_5axis_gantry.png`, `lib/python/vismach.py` | visualization | canvas nonblank smoke | | 3D 五轴预览 | `app/src/visualization/five-axis-scene.js` | `qtvismach_5axis_gantry.png`, `lib/python/vismach.py` | visualization | canvas nonblank smoke |
| Vismach transform tree | `app/src/visualization/machine-model.ts` | `lib/python/vismach.py`, `src/hal/user_comps/vismach/*.py` | visualization from GUI reference | scene graph smoke | | Vismach transform tree | `app/src/visualization/machine-model.ts` | `lib/python/vismach.py`, `src/hal/user_comps/vismach/*.py` | visualization from GUI reference | scene graph smoke |
| `xyzac-trt` profile | `app/src/profiles/xyzac-trt.js` | `configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini` | source/config reference | profile boundary node smoke | | `xyzac-trt` profile | `app/src/profiles/xyzac-trt.js` | `configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini` | source/config reference | profile boundary node smoke |
| `xyzac-trt` source reference map | `app/src/profiles/source-reference-map.js` | `xyzac-trt.ini`, `xyzac-trt.xml`, `switchkins_postgui.hal`, `xyzac-trt_cmds.hal`, `xyzac-trt-kins.c`, `trtfuncs.c`, `switchkins.c` | profile/source map only, not runtime proof | profile boundary node smoke | | `xyzac-trt` source reference map | `app/src/profiles/source-reference-map.js` | `xyzac-trt.ini`, `xyzac-trt.xml`, `switchkins_postgui.hal`, `xyzac-trt_cmds.hal`, `xyzac-trt-kins.c`, `trtfuncs.c`, `switchkins.c` | profile/source map only, not runtime proof | profile boundary node smoke |
| `xyzbc-trt` profile | `app/src/profiles/xyzbc-trt.ts` | `configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzbc-trt.ini` | source/config reference | profile node smoke | | `xyzbc-trt` profile | `app/src/profiles/xyzbc-trt.js` | `configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzbc-trt.ini` | source/config reference + runtime module switch | profile node smoke + browser profile switch smoke |
| SWITCHKINS panel | `app/src/panel-schema/xyzac-trt-pyvcp.js` | `xyzac-trt.xml`, `switchkins_postgui.hal` | UI/HAL binding reference | profile boundary node smoke + browser DOM smoke | | SWITCHKINS panel | `app/src/panel-schema/xyzac-trt-pyvcp.js` | `xyzac-trt.xml`, `switchkins_postgui.hal` | UI/HAL binding reference | profile boundary node smoke + browser DOM smoke |
| M428/M429/M430 state | `app/src/profiles/xyzac-trt.js`, `app/src/panel-schema/xyzac-trt-pyvcp.js` | `remap_subs/428remap.ngc`, `429remap.ngc`, `430remap.ngc` | LinuxCNC remap/source reference only until runtime adapter is connected | profile boundary node smoke | | M428/M429/M430 state | `app/src/profiles/xyzac-trt.js`, `app/src/panel-schema/xyzac-trt-pyvcp.js` | `remap_subs/428remap.ngc`, `429remap.ngc`, `430remap.ngc` | LinuxCNC remap/source reference only until runtime adapter is connected | profile boundary node smoke |
| LinuxCNC boundary adapter | `app/src/runtime/linuxcnc-boundary-adapter.js` | LinuxCNC interpreter/kinematics WASM future adapter point | adapter entrypoint only, runtime not connected | profile boundary node smoke + browser DOM smoke | | LinuxCNC boundary adapter | `app/src/runtime/linuxcnc-boundary-adapter.js` | LinuxCNC interpreter/kinematics/TP WASM adapter point | kinematics + interpreter + TP timing runtime connected; native task/realtime HAL still not ported | profile boundary node smoke + browser DOM smoke |
| Full execution boundary audit | `app/src/runtime/full-execution-boundary.js`, `app/src/state/store.js`, `app/src/ui/gmoccapy-shell.js` | LinuxCNC kinematics WASM, interpreter WASM, TP WASM, `runSimConfigProgram({ executionMode: "fiveAxisRemap" })`, TRT remap files | machine-file remap and TP timing ready; native task/realtime HAL blocked | full execution boundary node smoke + browser DOM smoke |
| Five-axis kinematics | `core/linuxcnc_kinematics_wasm` | `trtfuncs.c`, `xyzac-trt-kins.c`, `xyzbc-trt-kins.c`, `5axiskins.c` | LinuxCNC source-derived WASM | Node roundtrip smoke | | Five-axis kinematics | `core/linuxcnc_kinematics_wasm` | `trtfuncs.c`, `xyzac-trt-kins.c`, `xyzbc-trt-kins.c`, `5axiskins.c` | LinuxCNC source-derived WASM | Node roundtrip smoke |
| RTCP/TCP frame | `app/src/runtime/rtcp-frame.js` | LinuxCNC kinematics output + canonical events | fixture frame plumbing until kinematics WASM is ready | RTCP/store node smoke + browser DOM smoke | | RTCP/TCP frame | `app/src/runtime/rtcp-frame.js` | LinuxCNC kinematics output + canonical events | fixture frame plumbing until kinematics WASM is ready | RTCP/store node smoke + browser DOM smoke |
| OPFS session | `app/src/runtime/session-*` | current `wasm-port/runtime/opfs` | host-side persistence | save/restore smoke | | OPFS session | `app/src/runtime/five-axis-session.js` | current `wasm-port/runtime/opfs` | browser OPFS 5-axis session persistence | five_axis_session_smoke + browser save/restore smoke |
## 3. 源文件追溯清单 ## 3. 源文件追溯清单
@@ -118,11 +122,12 @@ src/emc/kinematics/kins_util.c
| --- | --- | --- | | --- | --- | --- |
| gmoccapy UI style | ready | 可直接 Web 化 | | gmoccapy UI style | ready | 可直接 Web 化 |
| Three.js preview | implemented_basic_canvas_scene | 已显示基础五轴机床、刀具/TCP marker、刀轴、刀路并消费 `rtcpFrame` | | Three.js preview | implemented_basic_canvas_scene | 已显示基础五轴机床、刀具/TCP marker、刀轴、刀路并消费 `rtcpFrame` |
| LinuxCNC interpreter WASM | existing_project_capability | 可参考 `wasm-port` 现有 SDK | | LinuxCNC interpreter WASM | browser_and_node_canonical_execution_ready | `createLinuxCncInterpSdk()` 已接入普通 G-code canonical motion executionremap/planner 未 promoted |
| LinuxCNC 5-axis kinematics WASM | node_proof_ready | `createLinuxCncKinematicsSdk({ moduleId: "xyzac-trt" })` 已由 web adapter 加载Node smoke 验证 forward/inverse frame | | LinuxCNC 5-axis kinematics WASM | browser_and_node_proof_ready | `createLinuxCncKinematicsSdk({ moduleId: "xyzac-trt" })` 已由 web adapter 加载Node/source-browser/dist-browser smoke 验证 forward/inverse frame |
| RTCP frame UI plumbing | implemented_fixture_and_kinematics_wasm | fixture fallback 仍为 `linuxCncKinematicsReady=false`Node kinematics proof 为 `source-derived-kinematics-wasm` / `linuxcnc_kinematics_wasm_c_abi` | | RTCP frame UI plumbing | implemented_fixture_and_kinematics_wasm | fixture fallback 仍为 `linuxCncKinematicsReady=false`browser/node kinematics proof 为 `source-derived-kinematics-wasm` / `linuxcnc_kinematics_wasm_c_abi` |
| Operator workflow | implemented_fixture_only | 上电/急停/复位/模式/JOG/MDI/G-code 加载/当前行显示已闭环;执行仍是 fixture line playback | | Operator workflow | implemented_linuxcnc_task_policy_with_canonical_motion | 上电/急停/复位/模式/JOG/MDI/RUN/PAUSE/RESUME/STOP/HOME 通过 `linuxcnc_task_state_mode_command_gate`;普通程序执行来自 LinuxCNC canonical motion |
| LinuxCNC boundary adapter | kinematics_runtime_connected_node | `web-rtcp-5axis-linuxcnc-boundary-adapter` 可区分 kinematics-only ready 与 interpreter/remap missing | | LinuxCNC boundary adapter | kinematics_and_interpreter_connected | `web-rtcp-5axis-linuxcnc-boundary-adapter` 可区分 kinematics-only ready 与 interpreter/remap missing |
| Full execution boundary audit | implemented_remap_ready_planner_task_hal_blocked | `web-rtcp-5axis-full-linuxcnc-execution-boundary` 汇总 kinematics/interpreter/machine-file-remap evidence仍明确 `plannerRuntimeReady=false``nativeTaskReady=false``nativeHalSyncReady=false``fullLinuxCncProgramExecutionReady=false` |
| PyVCP/HAL panel schema | implemented_reference_only | `xyzac-trt-switchkins-pyvcp` 已整理 SWITCHKINS 控件与 HAL nets不执行 native HAL | | PyVCP/HAL panel schema | implemented_reference_only | `xyzac-trt-switchkins-pyvcp` 已整理 SWITCHKINS 控件与 HAL nets不执行 native HAL |
| Python GUI runtime | not_ported | 只参考,不运行 | | Python GUI runtime | not_ported | 只参考,不运行 |
| Python remap runtime | blocked | 不在第一版实现 | | Python remap runtime | blocked | 不在第一版实现 |
@@ -465,3 +470,548 @@ Remaining risk:
Next: Next:
browser_kinematics_wasm_asset_worker_or_interpreter_execution_source browser_kinematics_wasm_asset_worker_or_interpreter_execution_source
``` ```
## 13. M7 追溯记录
```text
Batch: M7-browser-kinematics-runtime
Date: 2026-06-21 CST
Files changed:
app/package.json
app/scripts/build-static.mjs
app/src/main.js
app/src/runtime/linuxcnc-kinematics-runtime.js
tests/browser/gmoccapy_shell_smoke.html
tests/browser/verify_gmoccapy_dist_browser.sh
docs/development-continuation.md
docs/program-implementation-guide.md
docs/technical-roadmap.md
docs/traceability-matrix.md
Feature:
将浏览器主流程接入 xyzac-trt LinuxCNC kinematics WASM。
runtime loader 移除顶层 node:* 依赖,支持 browser ESM import
app/src/main.js 启动后自动 attach kinematics runtime
build-static 复制 linuxcnc-kinematics.js 与 kinematics .js/.wasm 产物到 dist
browser smoke 同时覆盖源码入口和 dist 入口。
LinuxCNC references:
wasm-port/runtime/sdk/src/linuxcnc-kinematics.js
wasm-port/build/wasm/kinematics/linuxcnc_xyzac_trt_kinematics.js
wasm-port/build/wasm/kinematics/linuxcnc_xyzac_trt_kinematics.wasm
src/emc/kinematics/xyzac-trt-kins.c
Boundary:
sourceMode=source-derived-kinematics-wasm
semanticBoundary=linuxcnc_kinematics_wasm_c_abi
browserKinematicsReady=true
nodeKinematicsReady=true
fullLinuxCncProgramExecutionReady=false
fixtureFallback=available_only_on_runtime_load_failure
Tests:
npm --prefix web-rtcp-5axis-sim-plan/app run build
npm --prefix web-rtcp-5axis-sim-plan/app run smoke:node
npm --prefix web-rtcp-5axis-sim-plan/app run smoke
Result:
gmoccapy_static_build=ok
linuxcnc_kinematics_runtime_smoke=ok
rtcp_store_smoke=ok
profile_boundary_smoke=ok
gmoccapy_shell_smoke=ok
gmoccapy_dist_smoke=ok
Remaining risk:
Superseded by M8/M9: browser kinematics now runs through Worker and ordinary
G-code RUN/STEP uses LinuxCNC interpreter canonical events. Remap/planner
execution remains not promoted.
Next:
linuxcnc_interpreter_remap_planner_execution_source_or_worker_isolation
```
## 14. M8-M9 追溯记录
```text
Batch: M8-kinematics-worker-runtime
Batch: M9-interpreter-canonical-program-execution
Date: 2026-06-21 CST
Files changed:
app/package.json
app/scripts/build-static.mjs
app/src/main.js
app/src/runtime/linuxcnc-interpreter-runtime.js
app/src/runtime/linuxcnc-kinematics-runtime.js
app/src/runtime/linuxcnc-kinematics-worker.js
app/src/runtime/linuxcnc-kinematics-worker-client.js
app/src/runtime/linuxcnc-boundary-adapter.js
app/src/state/store.js
app/src/ui/gmoccapy-shell.js
tests/node/verify_linuxcnc_interpreter_runtime.mjs
tests/node/verify_linuxcnc_kinematics_runtime.mjs
tests/node/verify_rtcp_store.mjs
tests/browser/gmoccapy_shell_smoke.html
Feature:
浏览器默认通过 Web Worker 加载 xyzac-trt LinuxCNC kinematics WASM
普通 G-code 程序通过 LinuxCNC interpreter WASM 执行并输出 canonical
motion eventsRUN/STEP 使用 canonical motion line/pose 更新 G-code
highlight、DRO、RTCP frame 和 Three.js preview。
LinuxCNC references:
wasm-port/runtime/sdk/src/linuxcnc-kinematics.js
wasm-port/runtime/sdk/src/linuxcnc-interp.js
wasm-port/build/wasm/kinematics/linuxcnc_xyzac_trt_kinematics.wasm
wasm-port/build/wasm/core/linuxcnc_interp.wasm
Boundary:
kinematics sourceMode=source-derived-kinematics-wasm
kinematics executionContext=worker in browser
interpreter sourceMode=linuxcnc-interpreter-wasm
interpreter semanticBoundary=linuxcnc_interpreter_wasm_canonical_events
remapRuntimeReady=false
plannerRuntimeReady=false
fullLinuxCncProgramExecutionReady=false
Tests:
npm --prefix web-rtcp-5axis-sim-plan/app run build
npm --prefix web-rtcp-5axis-sim-plan/app run smoke:node
npm --prefix web-rtcp-5axis-sim-plan/app run smoke
Result:
gmoccapy_static_build=ok
linuxcnc_kinematics_runtime_smoke=ok
linuxcnc_interpreter_runtime_smoke=ok
rtcp_store_smoke=ok
profile_boundary_smoke=ok
gmoccapy_shell_smoke=ok
gmoccapy_dist_smoke=ok
Remaining risk:
Interpreter runtime currently runs on the main browser thread; remap/planner,
OPFS machine-session staging, Python remap, tool DB, and external user-M process
remain not promoted.
Next:
remap_planner_worker_interpreter_or_opfs_machine_session_integration
```
## 15. M10-M11 追溯记录
```text
Batch: M10-profile-switching
Batch: M11-profile-session-business-closure
Date: 2026-06-21 CST
Files changed:
app/src/profiles/index.js
app/src/profiles/xyzbc-trt.js
app/src/profiles/source-reference-map.js
app/src/runtime/five-axis-session.js
app/src/runtime/rtcp-frame.js
app/src/state/store.js
app/src/ui/gmoccapy-shell.js
app/src/styles/gmoccapy.css
tests/node/verify_five_axis_session.mjs
tests/node/verify_profile_boundary.mjs
tests/node/verify_rtcp_store.mjs
tests/browser/gmoccapy_shell_smoke.html
Feature:
增加 xyzbc-trt profile、source reference map、PyVCP SWITCHKINS schema
变体和 UI profile selectorprofile 切换后重载对应 LinuxCNC kinematics WASM。
增加 web-rtcp-5axis OPFS session snapshot可保存/恢复 profile、program、
axis/TCP pose、RTCP state、canonical execution summary、runtime readiness 和 preview state。
LinuxCNC references:
configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzbc-trt.ini
configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzbc-trt.xml
src/emc/kinematics/xyzbc-trt-kins.c
wasm-port/runtime/opfs snapshot/file-service pattern
Boundary:
profile switching=source/config reference plus source-derived kinematics runtime reload
session persistence=browser OPFS UI/runtime state snapshot
machine-file staging=false
remapRuntimeReady=false
plannerRuntimeReady=false
Tests:
npm --prefix web-rtcp-5axis-sim-plan/app run build
npm --prefix web-rtcp-5axis-sim-plan/app run smoke:node
npm --prefix web-rtcp-5axis-sim-plan/app run smoke
Result:
gmoccapy_static_build=ok
linuxcnc_kinematics_runtime_smoke=ok
linuxcnc_interpreter_runtime_smoke=ok
five_axis_session_smoke=ok
rtcp_store_smoke=ok
profile_boundary_smoke=ok
gmoccapy_shell_smoke=ok
gmoccapy_dist_smoke=ok
Remaining risk:
Session snapshot persists Web simulation state, not full LinuxCNC machine-file
staging. Interpreter still runs on the main browser thread. Remap/planner,
Python remap, tool DB, and external user-M process remain not promoted.
Next:
interpreter_worker_or_opfs_machine_file_staging_or_remap_planner_boundary
```
## 17. M18 追溯记录
```text
Batch: M18-linuxcnc-task-policy-controls
Date: 2026-06-21 CST
Files changed:
app/src/state/linuxcnc-task-policy.js
app/src/runtime/execution-timing.js
app/src/state/store.js
app/src/ui/gmoccapy-shell.js
tests/node/verify_rtcp_store.mjs
tests/node/verify_five_axis_session.mjs
tests/browser/gmoccapy_shell_smoke.html
Feature:
Adds a source-referenced LinuxCNC task policy gate for operator controls:
TOGGLE_POWER, RESET, ESTOP, SET_MODE, JOG, RUN_MDI, RUN, STEP, HOME,
PAUSE, RESUME, STOP, and ABORT. The gmoccapy diagnostics panel now exposes
task state/mode/interpreter state, gate readiness, and the source file list
including `linuxcnc/src/emc/task/emctaskmain.cc`.
Adds LinuxCNC TP queue timing from interpreter canonical motion events.
UI now reports total/elapsed/remaining time and current segment
duration/velocity from the TP WASM runtime when available.
LinuxCNC references:
src/emc/nml_intf/emc.hh
src/emc/task/emctaskmain.cc
src/emc/task/emctask.cc
Boundary:
taskPolicyBoundary=linuxcnc_task_state_mode_command_gate
timingBoundary=linuxcnc_tp_queue_runtime_timing_from_canonical_motion
sourceMode=linuxcnc-task-source-referenced-policy
nativeTaskReady=false
nativeHalSyncReady=false
plannerRuntimeReady=true
fullLinuxCncProgramExecutionReady=false
Tests:
npm --prefix web-rtcp-5axis-sim-plan/app run smoke:node
npm --prefix web-rtcp-5axis-sim-plan/app run smoke
Result:
rtcp_store_smoke=ok
five_axis_session_smoke=ok
gmoccapy_shell_smoke=ok
gmoccapy_dist_smoke=ok
Remaining risk:
The control gates are aligned to LinuxCNC task source behavior, but this is
still a Web policy mirror. The project does not run the native LinuxCNC task
process, NML IPC loop, or realtime HAL synchronization. TP timing is computed
by the vendored LinuxCNC TP queue runtime from canonical motion events; it is
still not hardware drive timing and does not make native task/HAL ready.
Next:
decide_release_handoff_or_start_native_task_hal_planner_boundary
```
## 17. M13 追溯记录
```text
Batch: M13-interpreter-worker-runtime
Date: 2026-06-21 CST
Files changed:
app/src/main.js
app/src/runtime/linuxcnc-interpreter-worker.js
app/src/runtime/linuxcnc-interpreter-worker-client.js
app/src/state/store.js
app/src/ui/gmoccapy-shell.js
tests/browser/gmoccapy_shell_smoke.html
tests/node/verify_five_axis_session.mjs
tests/node/verify_rtcp_store.mjs
Feature:
Browser LinuxCNC interpreter WASM now loads through a Web Worker by default,
matching the existing kinematics Worker isolation. Program execution in store
accepts async interpreter runtimes, exposes interpreterExecutionPending, and
uses a sequence guard so stale Worker results cannot overwrite newer program
loads.
LinuxCNC references:
wasm-port/runtime/sdk/src/linuxcnc-interp.js
wasm-port/build/wasm/core/linuxcnc_interp.js
wasm-port/build/wasm/core/linuxcnc_interp.wasm
Boundary:
browserInterpreterExecutionContext=worker
nodeInterpreterExecutionContext=direct
interpreter semanticBoundary=linuxcnc_interpreter_wasm_canonical_events
remapRuntimeReady=false
plannerRuntimeReady=false
fullLinuxCncProgramExecutionReady=false
Tests:
npm --prefix web-rtcp-5axis-sim-plan/app run smoke:node
npm --prefix web-rtcp-5axis-sim-plan/app run build
npm --prefix web-rtcp-5axis-sim-plan/app run smoke
Result:
linuxcnc_kinematics_runtime_smoke=ok
linuxcnc_interpreter_runtime_smoke=ok
five_axis_session_smoke=ok
rtcp_store_smoke=ok
profile_boundary_smoke=ok
gmoccapy_static_build=ok
gmoccapy_shell_smoke=ok
gmoccapy_dist_smoke=ok
Remaining risk:
Interpreter isolation is complete for browser canonical execution, but this
still does not promote native LinuxCNC remap/planner, tool DB, task/HAL sync,
or external user-M process.
Next:
opfs_machine_file_staging_or_remap_planner_boundary
```
## 18. M14 追溯记录
```text
Batch: M14-opfs-machine-file-staging
Date: 2026-06-21 CST
Files changed:
app/package.json
app/scripts/build-static.mjs
app/src/main.js
app/src/runtime/linuxcnc-machine-file-staging.js
app/src/state/store.js
app/src/ui/gmoccapy-shell.js
tests/node/verify_machine_file_staging.mjs
tests/browser/gmoccapy_shell_smoke.html
Feature:
Adds LinuxCNC TRT machine-file staging for the web simulator. The runtime
reuses `planSimConfigStaging()` to collect INI, tool table, HAL/PyVCP assets,
remap NGC files, and demo G-code from the vendored LinuxCNC tree, then persists
those text files into OPFS-style machine paths. Store exposes stageMachineFiles()
and gmoccapy diagnostics render staging status.
LinuxCNC references:
wasm-port/runtime/sdk/src/sim-config-staging.js
wasm-port/tools/source-manifest.txt
configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini
configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzbc-trt.ini
configs/sim/axis/vismach/5axis/table-rotary-tilting/remap_subs/*.ngc
configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/*.ngc
Boundary:
machineFileStagingBoundary=linuxcnc_sim_config_file_staging_plan_plus_opfs_text_persistence
stagedFiles=ini,hal,pyvcp,tool_table,remap_ngc,demo_gcode
nativeHalTaskSync=false
remapRuntimeReady=false
plannerRuntimeReady=false
fullLinuxCncProgramExecutionReady=false
Tests:
npm --prefix web-rtcp-5axis-sim-plan/app run smoke:node
npm --prefix web-rtcp-5axis-sim-plan/app run build
npm --prefix web-rtcp-5axis-sim-plan/app run smoke
Result:
machine_file_staging_smoke=ok
linuxcnc_kinematics_runtime_smoke=ok
linuxcnc_interpreter_runtime_smoke=ok
five_axis_session_smoke=ok
rtcp_store_smoke=ok
profile_boundary_smoke=ok
gmoccapy_static_build=ok
gmoccapy_shell_smoke=ok
gmoccapy_dist_smoke=ok
Remaining risk:
Machine files are staged and persisted as browser/host text assets. This does
not yet execute a machine-file backed LinuxCNC task session, native HAL sync,
Python/NGC remap runtime, full planner, tool DB, or external user-M process.
Next:
remap_planner_boundary_or_machine_file_backed_run
```
## 19. M15 追溯记录
```text
Batch: M15-machine-file-backed-fiveaxis-remap-run
Date: 2026-06-21 CST
Files changed:
app/src/runtime/linuxcnc-interpreter-runtime.js
app/src/runtime/linuxcnc-interpreter-worker.js
app/src/runtime/linuxcnc-interpreter-worker-client.js
app/src/runtime/linuxcnc-machine-file-staging.js
app/src/state/store.js
app/src/ui/gmoccapy-shell.js
tests/node/verify_machine_file_staging.mjs
tests/browser/gmoccapy_shell_smoke.html
Feature:
Adds machine-file backed five-axis remap execution. The simulator now stages
TRT machine files, forwards the staged INI/tool table/remap/demo files into
the LinuxCNC interpreter WASM filesystem, and calls
`runSimConfigProgram({ executionMode: "fiveAxisRemap" })`. Node and browser
smokes verify LinuxCNC reports INI open, remap readiness, and program exit.
LinuxCNC references:
wasm-port/runtime/sdk/src/linuxcnc-interp.js
wasm-port/runtime/sdk/src/sim-config-staging.js
configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini
configs/sim/axis/vismach/5axis/table-rotary-tilting/remap_subs/428remap.ngc
configs/sim/axis/vismach/5axis/table-rotary-tilting/remap_subs/429remap.ngc
configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/xyzac_switchkins.ngc
Boundary:
machineFileBackedRunBoundary=linuxcnc_fiveaxis_remap_wasm_machine_file_execution
sourceMode=linuxcnc-machine-file-remap-wasm
remapRuntimeReady=vendored_linuxcnc_fiveaxis_remap_c_abi_for_staged_files
nativeTaskHalSync=false
plannerRuntimeReady=false
fullLinuxCncProgramExecutionReady=false
Tests:
npm --prefix web-rtcp-5axis-sim-plan/app run smoke:node
npm --prefix web-rtcp-5axis-sim-plan/app run build
npm --prefix web-rtcp-5axis-sim-plan/app run smoke
Result:
machine_file_staging_smoke=ok
linuxcnc_interpreter_runtime_smoke=ok
rtcp_store_smoke=ok
gmoccapy_shell_smoke=ok
gmoccapy_dist_smoke=ok
Remaining risk:
The run uses vendored LinuxCNC five-axis remap C ABI for staged files, but
still does not promote full native task/HAL synchronization, trajectory
planner runtime, tool DB process, or external user-M process.
Next:
planner_boundary_or_full_task_hal_gap_closure
```
## 20. M16 追溯记录
```text
Batch: M16-planner-task-hal-boundary-audit
Date: 2026-06-21 CST
Files changed:
app/package.json
app/src/runtime/full-execution-boundary.js
app/src/runtime/linuxcnc-interpreter-runtime.js
app/src/state/store.js
app/src/ui/gmoccapy-shell.js
tests/node/verify_full_execution_boundary.mjs
tests/node/verify_linuxcnc_interpreter_runtime.mjs
tests/node/verify_machine_file_staging.mjs
tests/node/verify_rtcp_store.mjs
tests/browser/gmoccapy_shell_smoke.html
Feature:
Adds a full LinuxCNC execution boundary audit layer. The Web simulator now
summarizes kinematics WASM, interpreter canonical motion, machine-file
staging, five-axis remap C ABI run, and switchkins HAL evidence in one
explicit diagnostics contract. It also adds an Audit control that can trigger
staging plus machine-file backed remap execution from the gmoccapy shell.
LinuxCNC references:
wasm-port/runtime/sdk/src/linuxcnc-interp.js
wasm-port/runtime/sdk/src/linuxcnc-hal.js
configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini
configs/sim/axis/vismach/5axis/table-rotary-tilting/remap_subs/428remap.ngc
configs/sim/axis/vismach/5axis/table-rotary-tilting/remap_subs/429remap.ngc
configs/sim/axis/vismach/5axis/table-rotary-tilting/remap_subs/430remap.ngc
Boundary:
fullExecutionBoundary=linuxcnc_machine_file_remap_ready_planner_task_hal_blocked
remapRuntimeReady=true for staged fiveAxisRemap C ABI run
halSwitchkinsEvidenceReady=true for fiveaxis_hal_switchkins evidence
nativeTaskReady=false
nativeHalSyncReady=false
plannerRuntimeReady=false
fullLinuxCncProgramExecutionReady=false
promotionAllowed=false
Tests:
npm --prefix web-rtcp-5axis-sim-plan/app run smoke:node
npm --prefix web-rtcp-5axis-sim-plan/app run build
npm --prefix web-rtcp-5axis-sim-plan/app run smoke
Result:
full_execution_boundary_smoke=ok
machine_file_staging_smoke=ok
linuxcnc_interpreter_runtime_smoke=ok
rtcp_store_smoke=ok
gmoccapy_shell_smoke=ok
gmoccapy_dist_smoke=ok
Remaining risk:
Full native LinuxCNC task/NML process, realtime HAL thread synchronization,
trajectory planner queue runtime, external user-M process, and full tool DB
process are still not ported or promoted.
Next:
optional_native_task_hal_planner_port_or_release_handoff
```
## 21. M17 追溯记录
```text
Batch: M17-linuxcnc-5axis-gcode-source-ingest
Date: 2026-06-21 CST
Files changed:
app/src/runtime/linuxcnc-machine-file-staging.js
app/src/runtime/linuxcnc-interpreter-runtime.js
app/src/state/store.js
app/src/ui/gmoccapy-shell.js
tests/node/verify_machine_file_staging.mjs
tests/browser/gmoccapy_shell_smoke.html
Feature:
Adds real vendored LinuxCNC five-axis G-code source ingestion. TRT demo
programs from `configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/*.ngc`
are staged, listed in the gmoccapy G-code panel, loadable into the program
viewer, and used as the selected `wasmProgramPath` for machine-file backed
fiveAxisRemap execution.
LinuxCNC references:
configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/boat-xyzac.ngc
configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/boat-xyzbc.ngc
configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/impeller-7bl-xyzac.ngc
configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/xyzac_switchkins.ngc
configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/xyzbc_switchkins.ngc
Boundary:
gcodeSourceBoundary=linuxcnc_vendored_5axis_gcode_source_file
sourceMode=linuxcnc-vendored-5axis-gcode
machineFileBackedRunProgram=selected_staged_linuxcnc_ngc_source
sourceDirectoryGuard=configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/*.ngc only
remapRuntimeReady=true for staged fiveAxisRemap C ABI run
plannerRuntimeReady=false
nativeTaskReady=false
nativeHalSyncReady=false
fullLinuxCncProgramExecutionReady=false
Tests:
npm --prefix web-rtcp-5axis-sim-plan/app run smoke:node
npm --prefix web-rtcp-5axis-sim-plan/app run build
npm --prefix web-rtcp-5axis-sim-plan/app run smoke
Result:
machine_file_staging_smoke=ok
gmoccapy_shell_smoke=ok
gmoccapy_dist_smoke=ok
Remaining risk:
The selected G-code source is real LinuxCNC vendored source and the machine-file
run uses that selected file path, but full native task/NML, realtime HAL sync,
and trajectory planner queue runtime are still not promoted.
Next:
trajectory_planner_wasm_boundary_or_native_task_hal_port
```
## 16. M12 追溯记录
```text
Batch: M12-switchkins-rtcp-program-execution
Date: 2026-06-21 CST
Files changed:
app/src/runtime/linuxcnc-interpreter-runtime.js
app/src/runtime/linuxcnc-kinematics-runtime.js
app/src/runtime/linuxcnc-kinematics-worker.js
app/src/runtime/linuxcnc-kinematics-worker-client.js
app/src/state/store.js
app/src/ui/gmoccapy-shell.js
tests/node/verify_linuxcnc_interpreter_runtime.mjs
tests/node/verify_rtcp_store.mjs
tests/browser/gmoccapy_shell_smoke.html
Feature:
LinuxCNC TRT `M428/M429/M430` switchkins remap M-code is preserved as
program-level runtime events. The interpreter runtime strips those high M-codes
only from the WASM input program so LinuxCNC interpreter canonical motion can
continue, then annotates canonical motion with active switchkins state.
RUN/STEP/RUN_FRAME now applies program switchkins state to RTCP state and calls
LinuxCNC kinematics WASM `lckins_switch()` through direct and Worker runtimes.
LinuxCNC references:
configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini
configs/sim/axis/vismach/5axis/table-rotary-tilting/remap_subs/428remap.ngc
configs/sim/axis/vismach/5axis/table-rotary-tilting/remap_subs/429remap.ngc
configs/sim/axis/vismach/5axis/table-rotary-tilting/remap_subs/430remap.ngc
src/emc/kinematics/switchkins.c
wasm-port/runtime/sdk/src/linuxcnc-kinematics.js
Boundary:
switchkinsProgramBoundary=linuxcnc_remap_mcode_preserved_and_web_runtime_applied
interpreter sourceMode=linuxcnc-interpreter-wasm
interpreter semanticBoundary=linuxcnc_interpreter_wasm_canonical_events
kinematics semanticBoundary=linuxcnc_kinematics_wasm_c_abi
remapRuntimeReady=false
plannerRuntimeReady=false
fullLinuxCncProgramExecutionReady=false
Tests:
npm --prefix web-rtcp-5axis-sim-plan/app run smoke:node
npm --prefix web-rtcp-5axis-sim-plan/app run build
npm --prefix web-rtcp-5axis-sim-plan/app run smoke
Result:
linuxcnc_kinematics_runtime_smoke=ok
linuxcnc_interpreter_runtime_smoke=ok
five_axis_session_smoke=ok
rtcp_store_smoke=ok
profile_boundary_smoke=ok
gmoccapy_static_build=ok
gmoccapy_shell_smoke=ok
gmoccapy_dist_smoke=ok
Remaining risk:
This preserves and applies switchkins remap M-codes in Web runtime, but does not
execute LinuxCNC Python/NGC remap, native HAL/task synchronization, full planner,
tool DB, or external user-M process.
Next:
interpreter_worker_or_opfs_machine_file_staging_or_remap_planner_boundary
```

View File

@@ -0,0 +1,36 @@
#!/usr/bin/env bash
set -euo pipefail
APP_DIR="/home/cnc/桌面/cnc_wams/web-rtcp-5axis-sim-plan/app/dist"
PORT="8092"
URL="http://127.0.0.1:${PORT}/"
LOG_DIR="${HOME}/.cache/web-rtcp-5axis-sim"
LOG_FILE="${LOG_DIR}/local-server.log"
mkdir -p "$LOG_DIR"
is_serving() {
curl -fsS --max-time 1 "$URL" >/dev/null 2>&1
}
if ! is_serving; then
nohup python3 -m http.server "$PORT" --bind 127.0.0.1 --directory "$APP_DIR" >"$LOG_FILE" 2>&1 &
for _ in $(seq 1 40); do
if is_serving; then
break
fi
sleep 0.1
done
fi
if command -v google-chrome-stable >/dev/null 2>&1; then
exec google-chrome-stable --new-window "$URL"
elif command -v google-chrome >/dev/null 2>&1; then
exec google-chrome --new-window "$URL"
elif command -v chromium >/dev/null 2>&1; then
exec chromium --new-window "$URL"
elif command -v chromium-browser >/dev/null 2>&1; then
exec chromium-browser --new-window "$URL"
else
exec xdg-open "$URL"
fi

View File

@@ -6,12 +6,34 @@
</head> </head>
<body> <body>
<pre id="result">gmoccapy_shell_smoke=pending</pre> <pre id="result">gmoccapy_shell_smoke=pending</pre>
<iframe id="app-frame" src="../../app/index.html" title="gmoccapy shell"></iframe> <iframe id="app-frame" title="gmoccapy shell"></iframe>
<script type="module"> <script type="module">
const result = document.querySelector("#result"); const result = document.querySelector("#result");
const frame = document.querySelector("#app-frame"); const frame = document.querySelector("#app-frame");
const appSrc = new URLSearchParams(window.location.search).get("app") || "../../app/index.html";
frame.src = appSrc;
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function waitForInterpreterExecution(win) {
for (let attempt = 0; attempt < 40; attempt += 1) {
const state = win.webRtcp5AxisSimulation.getState();
if (!state.interpreterExecutionPending && state.programExecutionSourceMode === "linuxcnc-interpreter-wasm") {
return state;
}
await wait(25);
}
return win.webRtcp5AxisSimulation.getState();
}
async function waitForMachineFileExecution(win) {
for (let attempt = 0; attempt < 60; attempt += 1) {
const state = win.webRtcp5AxisSimulation.getState();
if (!state.interpreterExecutionPending && state.machineFileExecution) {
return state;
}
await wait(25);
}
return win.webRtcp5AxisSimulation.getState();
}
async function runSmoke() { async function runSmoke() {
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
@@ -89,25 +111,84 @@
throw new Error(`forbidden frontend framework dependency detected: ${forbidden.join(", ")}`); throw new Error(`forbidden frontend framework dependency detected: ${forbidden.join(", ")}`);
} }
const runtimeReadiness = await win.webRtcp5AxisSimulation.kinematicsRuntimeReady;
if (runtimeReadiness.loaded !== true || runtimeReadiness.moduleId !== "xyzac-trt") {
throw new Error(`LinuxCNC kinematics runtime did not load in browser: ${JSON.stringify(runtimeReadiness)}`);
}
if (runtimeReadiness.executionContext !== "worker") {
throw new Error(`LinuxCNC kinematics runtime should run in worker: ${JSON.stringify(runtimeReadiness)}`);
}
const interpreterReadiness = await win.webRtcp5AxisSimulation.interpreterRuntimeReady;
if (interpreterReadiness.loaded !== true || interpreterReadiness.runProgramReady !== true) {
throw new Error(`LinuxCNC interpreter runtime did not load in browser: ${JSON.stringify(interpreterReadiness)}`);
}
if (interpreterReadiness.executionContext !== "worker") {
throw new Error(`LinuxCNC interpreter runtime should run in worker: ${JSON.stringify(interpreterReadiness)}`);
}
if (
interpreterReadiness.plannerRuntimeReady !== true ||
interpreterReadiness.plannerSemanticBoundary !== "linuxcnc_tp_queue_runtime_timing_from_canonical_motion"
) {
throw new Error(`LinuxCNC TP planner runtime did not load in browser worker: ${JSON.stringify(interpreterReadiness)}`);
}
const state = win.webRtcp5AxisSimulation.getState(); const state = win.webRtcp5AxisSimulation.getState();
if (state.sourceMode !== "fixture-ui-only") { if (state.sourceMode !== "source-derived-kinematics-wasm") {
throw new Error(`unexpected source mode: ${state.sourceMode}`); throw new Error(`unexpected source mode: ${state.sourceMode}`);
} }
if (state.machineProfile !== "xyzac-trt") { if (state.kinematicsExecutionContext !== "worker") {
throw new Error(`unexpected profile: ${state.machineProfile}`); throw new Error(`unexpected kinematics execution context: ${state.kinematicsExecutionContext}`);
}
const profileSelector = doc.querySelector('[data-action="select-profile"]');
if (!profileSelector || profileSelector.options.length < 2) {
throw new Error("missing five-axis profile selector");
}
profileSelector.value = "xyzbc-trt";
profileSelector.dispatchEvent(new Event("change", { bubbles: true }));
await wait(500);
await win.webRtcp5AxisSimulation.refreshKinematicsFrame({ operatorMessage: "browser smoke refreshed xyzbc frame" });
const xyzbcState = win.webRtcp5AxisSimulation.getState();
if (xyzbcState.machineProfile !== "xyzbc-trt" || xyzbcState.profile.traj.coordinates !== "XYZBC") {
throw new Error("profile selector did not switch to XYZBC");
}
if (xyzbcState.kinematicsRuntimeReadiness?.moduleId !== "xyzbc-trt") {
throw new Error(`XYZBC kinematics runtime did not load: ${JSON.stringify(xyzbcState.kinematicsRuntimeReadiness)}`);
}
const restoredProfileSelector = doc.querySelector('[data-action="select-profile"]');
restoredProfileSelector.value = "xyzac-trt";
restoredProfileSelector.dispatchEvent(new Event("change", { bubbles: true }));
await wait(500);
await win.webRtcp5AxisSimulation.refreshKinematicsFrame({ operatorMessage: "browser smoke restored xyzac frame" });
const restoredProfileState = win.webRtcp5AxisSimulation.getState();
if (restoredProfileState.machineProfile !== "xyzac-trt") {
throw new Error(`unexpected profile: ${restoredProfileState.machineProfile}`);
} }
if (state.rtcpFrame?.apiName !== "web-rtcp-5axis-motion-frame") { if (state.rtcpFrame?.apiName !== "web-rtcp-5axis-motion-frame") {
throw new Error("missing RTCP frame state"); throw new Error("missing RTCP frame state");
} }
if (state.rtcpFrame?.readiness?.linuxCncKinematicsReady !== false) { if (state.rtcpFrame?.readiness?.linuxCncKinematicsReady !== true) {
throw new Error("fixture RTCP frame must not claim LinuxCNC kinematics readiness"); throw new Error("browser RTCP frame must use LinuxCNC kinematics readiness");
} }
if (!doc.querySelector('[data-rtcp-value="tcp"]')?.textContent.includes("TCP")) { if (!doc.querySelector('[data-rtcp-value="tcp"]')?.textContent.includes("TCP")) {
throw new Error("missing TCP DRO strip"); throw new Error("missing TCP DRO strip");
} }
if (!doc.querySelector('[data-rtcp-diagnostic="boundary"]')?.textContent.includes("fixture_frame_ui_plumbing")) { if (!doc.querySelector('[data-rtcp-diagnostic="boundary"]')?.textContent.includes("linuxcnc_kinematics_wasm_c_abi")) {
throw new Error("missing RTCP boundary diagnostic"); throw new Error("missing RTCP boundary diagnostic");
} }
if (!doc.querySelector('[data-linuxcnc-ini="status"]')?.textContent.includes("xyzac-trt.ini")) {
throw new Error("missing LinuxCNC INI status diagnostic");
}
if (!doc.querySelector('[data-linuxcnc-ini="kins"]')?.textContent.includes("xyzac-trt-kins")) {
throw new Error("missing LinuxCNC INI kinematics diagnostic");
}
if (!doc.querySelector('[data-linuxcnc-ini="limits"]')?.textContent.includes("X[-200,200]")) {
throw new Error("missing LinuxCNC INI axis limits diagnostic");
}
if (!doc.querySelector('[data-linuxcnc-task-policy="boundary"]')?.textContent.includes("linuxcnc_task_state_mode_command_gate")) {
throw new Error("missing LinuxCNC task policy boundary diagnostic");
}
if (!doc.querySelector('[data-linuxcnc-task-policy="source"]')?.textContent.includes("linuxcnc/src/emc/task/emctaskmain.cc")) {
throw new Error("missing LinuxCNC emctaskmain source diagnostic");
}
if (!doc.querySelector('[data-tool-preview="summary"]')?.textContent.includes("T1")) { if (!doc.querySelector('[data-tool-preview="summary"]')?.textContent.includes("T1")) {
throw new Error("missing tool preview summary"); throw new Error("missing tool preview summary");
} }
@@ -131,11 +212,19 @@
if (!doc.querySelector('[data-machine-state="summary"]')?.textContent.includes("power on")) { if (!doc.querySelector('[data-machine-state="summary"]')?.textContent.includes("power on")) {
throw new Error("machine state did not render power on"); throw new Error("machine state did not render power on");
} }
doc.querySelector('[data-action="HOME"]').click();
await wait(50);
doc.querySelector('[data-action="mode-auto"]').click();
await wait(50);
if (!doc.querySelector('[data-linuxcnc-task-policy="gates"]')?.textContent.includes("auto")) {
throw new Error("LinuxCNC task policy did not expose auto run gate");
}
win.webRtcp5AxisSimulation.dispatch({ win.webRtcp5AxisSimulation.dispatch({
type: "LOAD_PROGRAM", type: "LOAD_PROGRAM",
filename: "operator-demo.ngc", filename: "operator-demo.ngc",
content: [ content: [
"G90 G17",
"G0 X0 Y0 Z0", "G0 X0 Y0 Z0",
"G1 X10 F100", "G1 X10 F100",
"G1 Y10", "G1 Y10",
@@ -143,18 +232,154 @@
"G1 Y0", "G1 Y0",
"G0 Z5", "G0 Z5",
"M5", "M5",
"M30", "M2",
].join("\n"), ].join("\n"),
}); });
await wait(50); await waitForInterpreterExecution(win);
if (win.webRtcp5AxisSimulation.getState().activeProgram !== "operator-demo.ngc") { if (win.webRtcp5AxisSimulation.getState().activeProgram !== "operator-demo.ngc") {
throw new Error("LOAD_PROGRAM did not update active program"); throw new Error("LOAD_PROGRAM did not update active program");
} }
if (doc.querySelector('[data-active-program-line]')?.textContent !== "Current line 1") { if (win.webRtcp5AxisSimulation.getState().programExecutionSourceMode !== "linuxcnc-interpreter-wasm") {
throw new Error("loaded program did not render current line 1"); throw new Error("LOAD_PROGRAM did not execute through LinuxCNC interpreter WASM");
} }
if (doc.querySelector(".gcode-row.active")?.dataset.programLine !== "1") { if (win.webRtcp5AxisSimulation.getState().programExecution?.summary?.motionEventCount < 5) {
throw new Error("loaded program active row should be line 1"); throw new Error("LinuxCNC interpreter execution did not produce motion events");
}
if (win.webRtcp5AxisSimulation.getState().programExecution?.summary?.plannerRuntimeReady !== true) {
throw new Error("LinuxCNC TP planner timing was not ready for operator program");
}
if (win.webRtcp5AxisSimulation.getState().programExecutionTiming?.semanticBoundary !== "linuxcnc_tp_queue_runtime_timing_from_canonical_motion") {
throw new Error(`program timing did not use LinuxCNC TP queue runtime: ${JSON.stringify(win.webRtcp5AxisSimulation.getState().programExecutionTiming)}`);
}
if (win.webRtcp5AxisSimulation.getState().programExecutionTiming?.sampleCount < 2) {
throw new Error("LinuxCNC TP runtime feedback samples were not exposed");
}
if (win.webRtcp5AxisSimulation.getState().programRuntimeFeedback?.semanticBoundary !== "linuxcnc_tp_run_cycle_feedback_without_hardware") {
throw new Error(`initial runtime feedback did not use LinuxCNC TP sample: ${JSON.stringify(win.webRtcp5AxisSimulation.getState().programRuntimeFeedback)}`);
}
if (!doc.querySelector('[data-program-execution-source]')?.textContent.includes("linuxcnc-interpreter-wasm")) {
throw new Error("program execution source DOM did not render interpreter source");
}
if (!doc.querySelector('[data-program-execution-summary]')?.textContent.includes("motion")) {
throw new Error("program execution summary DOM did not render motion count");
}
if (!doc.querySelector('[data-program-timing="summary"]')?.textContent.match(/\\d+:\\d/)) {
throw new Error("program timing summary DOM did not render estimated execution time");
}
if (!doc.querySelector('[data-program-timing="segment"]')?.textContent.includes("mm/min")) {
throw new Error("program timing segment DOM did not render segment velocity");
}
if (!doc.querySelector('[data-program-runtime-feedback="source"]')?.textContent.includes("linuxcnc-tp-runtime-sample")) {
throw new Error("program runtime feedback DOM did not render LinuxCNC TP sample source");
}
if (!doc.querySelector('[data-program-runtime-feedback="dtg"]')?.textContent.includes("DTG")) {
throw new Error("program runtime feedback DOM did not render DTG");
}
if (!doc.querySelector('[data-program-switchkins-summary]')?.textContent.includes("0 events")) {
throw new Error("program switchkins summary DOM did not render zero-event state");
}
win.webRtcp5AxisSimulation.dispatch({
type: "LOAD_PROGRAM",
filename: "operator-arc-demo.ngc",
content: [
"G90 G17",
"G0 X1 Y0 Z0",
"G2 X0 Y1 I-1 J0 F60",
"M2",
].join("\n"),
});
await waitForInterpreterExecution(win);
const arcState = win.webRtcp5AxisSimulation.getState();
if (!arcState.programExecution?.summary?.motionTypes?.includes("ARC_FEED")) {
throw new Error("LinuxCNC interpreter did not produce ARC_FEED for browser arc program");
}
if (arcState.programExecution?.summary?.plannerRuntimeReady !== true) {
throw new Error("LinuxCNC TP planner timing was not ready for browser arc program");
}
const arcSegment = arcState.programExecutionTiming?.segments?.find((segment) => segment.type === "ARC_FEED");
if (!arcSegment || arcSegment.durationSeconds <= 1.4) {
throw new Error(`LinuxCNC TP arc timing segment missing or too short: ${JSON.stringify(arcState.programExecutionTiming)}`);
}
const stagedMachineFiles = await win.webRtcp5AxisSimulation.stageMachineFiles();
await wait(50);
if (stagedMachineFiles.save.status !== "saved" || stagedMachineFiles.save.fileCount < 5) {
throw new Error(`machine file staging did not save files: ${JSON.stringify(stagedMachineFiles.save)}`);
}
if (!stagedMachineFiles.save.files.some((file) => file.sourceRel.endsWith("remap_subs/428remap.ngc"))) {
throw new Error("machine file staging did not include M428 remap");
}
if (!doc.querySelector('[data-machine-file-staging="status"]')?.textContent.includes("staged")) {
throw new Error("machine file staging status did not render");
}
const sourceSelect = doc.querySelector('[data-action="select-linuxcnc-gcode-source"]');
if (!sourceSelect || sourceSelect.options.length < 4) {
throw new Error("LinuxCNC 5-axis G-code source selector did not render staged demos");
}
const impellerSource = "configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/impeller-7bl-xyzac.ngc";
sourceSelect.value = impellerSource;
sourceSelect.dispatchEvent(new Event("change", { bubbles: true }));
await waitForInterpreterExecution(win);
const linuxCncSourceState = win.webRtcp5AxisSimulation.getState();
if (linuxCncSourceState.programSource !== "linuxcnc-vendored-5axis-gcode") {
throw new Error(`LinuxCNC source program was not loaded: ${linuxCncSourceState.programSource}`);
}
if (!linuxCncSourceState.activeProgram.endsWith("impeller-7bl-xyzac.ngc")) {
throw new Error(`unexpected LinuxCNC source program: ${linuxCncSourceState.activeProgram}`);
}
if (!doc.querySelector('[data-linuxcnc-gcode-source="selected"]')?.textContent.includes("impeller-7bl-xyzac.ngc")) {
throw new Error("selected LinuxCNC G-code source did not render");
}
if (!doc.querySelector('[data-program-source]')?.textContent.includes("linuxcnc-vendored-5axis-gcode")) {
throw new Error("program source DOM did not show LinuxCNC vendored source");
}
win.webRtcp5AxisSimulation.dispatch({ type: "RUN_MACHINE_FILE_PROGRAM" });
const machineFileRunState = await waitForMachineFileExecution(win);
if (machineFileRunState.machineFileExecution?.summary?.machineFileExecutionReady !== true) {
throw new Error(`machine-file backed remap run did not complete: ${JSON.stringify(machineFileRunState.machineFileExecution?.summary)}`);
}
if (!machineFileRunState.machineFileExecution.resultText.includes("fiveaxis_file_reached_exit=1")) {
throw new Error("machine-file backed remap run did not reach exit");
}
if (!machineFileRunState.machineFileExecution.machineFilePlan?.selectedProgramFilename?.includes("impeller-7bl-xyzac.ngc")) {
throw new Error("machine-file backed remap run did not use selected LinuxCNC G-code source");
}
if (!doc.querySelector('[data-machine-file-execution="status"]')?.textContent.includes("ready")) {
throw new Error("machine-file execution status did not render");
}
if (machineFileRunState.fullExecutionBoundary?.machineFileBackedRemapReady !== true) {
throw new Error(`full execution boundary did not record remap readiness: ${JSON.stringify(machineFileRunState.fullExecutionBoundary)}`);
}
if (machineFileRunState.fullExecutionBoundary?.fullLinuxCncProgramExecutionReady !== false) {
throw new Error("full LinuxCNC program execution must remain blocked without native task/planner/HAL");
}
if (!machineFileRunState.fullExecutionBoundary.blockers.some((blocker) => blocker.includes("trajectory planner"))) {
throw new Error("full execution boundary did not expose planner blocker");
}
if (!doc.querySelector('[data-full-execution-boundary="status"]')?.textContent.includes("remap ready")) {
throw new Error("full execution boundary status did not render remap readiness");
}
if (!doc.querySelector('[data-full-execution-boundary="status"]')?.textContent.includes("full blocked")) {
throw new Error("full execution boundary status did not render full blocked state");
}
if (!doc.querySelector('[data-full-execution-boundary="blockers"]')?.textContent.includes("native LinuxCNC task")) {
throw new Error("full execution blocker diagnostic did not render native task blocker");
}
if (!doc.querySelector('[data-full-execution-boundary="evidence"]')?.textContent.includes("linuxcnc_machine_file_remap_ready_planner_task_hal_blocked")) {
throw new Error("full execution evidence diagnostic did not render boundary semantic");
}
const savedSession = await win.webRtcp5AxisSimulation.saveSession();
await wait(50);
if (savedSession.snapshot.format !== "web-rtcp-5axis-session-snapshot") {
throw new Error("saveSession did not write a five-axis session snapshot");
}
if (!doc.querySelector('[data-session-persistence="status"]')?.textContent.includes("saved")) {
throw new Error("session save status did not render");
}
if (doc.querySelector('[data-active-program-line]')?.textContent !== "Current line 2") {
throw new Error("loaded program did not render first LinuxCNC motion line");
}
if (doc.querySelector(".gcode-row.active")?.dataset.programLine !== "2") {
throw new Error("loaded program active row should be first LinuxCNC motion line");
} }
win.webRtcp5AxisSimulation.dispatch({ type: "RUN" }); win.webRtcp5AxisSimulation.dispatch({ type: "RUN" });
@@ -162,8 +387,29 @@
if (win.webRtcp5AxisSimulation.getState().runState !== "running") { if (win.webRtcp5AxisSimulation.getState().runState !== "running") {
throw new Error("RUN action did not update state after power on"); throw new Error("RUN action did not update state after power on");
} }
if (doc.querySelector(".gcode-row.active")?.dataset.programLine !== "6") { if (win.webRtcp5AxisSimulation.getState().programRuntimeFeedback?.sourceMode !== "linuxcnc-tp-runtime-sample") {
throw new Error("RUN did not highlight the executing current line"); throw new Error("RUN did not advance with LinuxCNC TP runtime feedback");
}
if (win.webRtcp5AxisSimulation.getState().feed.currentVelocity <= 0) {
throw new Error("RUN did not expose LinuxCNC TP current velocity");
}
if (Number(doc.querySelector(".gcode-row.active")?.dataset.programLine || 0) < 2) {
throw new Error("RUN did not highlight a LinuxCNC canonical motion line");
}
doc.querySelector('[data-action="PAUSE"]').click();
await wait(50);
if (win.webRtcp5AxisSimulation.getState().runState !== "paused") {
throw new Error("PAUSE action did not update state");
}
doc.querySelector('[data-action="RUN"]').click();
await wait(50);
if (!win.webRtcp5AxisSimulation.getState().operatorMessage.includes("resume paused program")) {
throw new Error("RUN should be blocked while program is paused");
}
doc.querySelector('[data-action="RESUME"]').click();
await wait(50);
if (win.webRtcp5AxisSimulation.getState().runState !== "running") {
throw new Error("RESUME action did not restore running state");
} }
const tcpButton = doc.querySelector('[data-action="kins-tcp"]'); const tcpButton = doc.querySelector('[data-action="kins-tcp"]');
tcpButton.click(); tcpButton.click();
@@ -181,8 +427,11 @@
if (canvas.dataset.threeRtcpState !== "on") { if (canvas.dataset.threeRtcpState !== "on") {
throw new Error("Three.js preview did not consume RTCP enabled frame"); throw new Error("Three.js preview did not consume RTCP enabled frame");
} }
if (doc.querySelector('[data-rtcp-diagnostic="kinematics-ready"]')?.textContent !== "pending") { if (doc.querySelector('[data-rtcp-diagnostic="kinematics-ready"]')?.textContent !== "ready") {
throw new Error("RTCP diagnostics must keep LinuxCNC kinematics pending for fixture mode"); throw new Error("RTCP diagnostics must show LinuxCNC kinematics ready");
}
if (doc.querySelector('[data-rtcp-diagnostic="execution-context"]')?.textContent !== "worker") {
throw new Error("RTCP diagnostics must show worker kinematics context");
} }
if (!doc.querySelector('[data-linuxcnc-boundary="adapter"]')?.textContent.includes("linuxcnc-boundary-adapter")) { if (!doc.querySelector('[data-linuxcnc-boundary="adapter"]')?.textContent.includes("linuxcnc-boundary-adapter")) {
throw new Error("missing LinuxCNC boundary adapter diagnostic"); throw new Error("missing LinuxCNC boundary adapter diagnostic");
@@ -193,12 +442,19 @@
if (!doc.querySelector('[data-linuxcnc-boundary="profile-summary"]')?.textContent.includes("XYZAC / 5 joints / 10 tools")) { if (!doc.querySelector('[data-linuxcnc-boundary="profile-summary"]')?.textContent.includes("XYZAC / 5 joints / 10 tools")) {
throw new Error("missing LinuxCNC profile summary diagnostic"); throw new Error("missing LinuxCNC profile summary diagnostic");
} }
if (!doc.querySelector('[data-linuxcnc-boundary="readiness"]')?.textContent.includes("blocked")) { if (!doc.querySelector('[data-linuxcnc-boundary="readiness"]')?.textContent.includes("ready")) {
throw new Error("LinuxCNC boundary readiness should remain blocked"); throw new Error("LinuxCNC boundary readiness should show kinematics ready");
}
if (!doc.querySelector('[data-linuxcnc-boundary="interpreter"]')?.textContent.includes("linuxcnc_interpreter_wasm_canonical_events")) {
throw new Error("missing LinuxCNC interpreter boundary diagnostic");
}
if (doc.querySelector('[data-linuxcnc-boundary="interpreter-context"]')?.textContent !== "worker") {
throw new Error("LinuxCNC interpreter diagnostics must show worker context");
} }
canvas = doc.querySelector("[data-five-axis-canvas]"); canvas = doc.querySelector("[data-five-axis-canvas]");
const tcpPoseBeforeStep = canvas.dataset.threeTcpPose; const tcpPoseBeforeStep = canvas.dataset.threeTcpPose;
win.webRtcp5AxisSimulation.dispatch({ type: "STEP" }); win.webRtcp5AxisSimulation.dispatch({ type: "STEP" });
await win.webRtcp5AxisSimulation.refreshKinematicsFrame({ operatorMessage: "browser smoke refreshed worker frame" });
await wait(50); await wait(50);
if (win.webRtcp5AxisSimulation.getState().activeLine <= rtcpState.activeLine) { if (win.webRtcp5AxisSimulation.getState().activeLine <= rtcpState.activeLine) {
throw new Error("STEP did not advance RTCP frame line"); throw new Error("STEP did not advance RTCP frame line");
@@ -208,6 +464,13 @@
throw new Error("Three.js preview did not update TCP pose after STEP"); throw new Error("Three.js preview did not update TCP pose after STEP");
} }
assertCanvasNonblank(canvas, "updated Three.js preview"); assertCanvasNonblank(canvas, "updated Three.js preview");
doc.querySelector('[data-action="STOP"]').click();
await wait(50);
if (win.webRtcp5AxisSimulation.getState().machine.interpState !== "idle") {
throw new Error("STOP did not clear interpreter state");
}
doc.querySelector('[data-action="mode-manual"]').click();
await wait(50);
doc.querySelector('[data-action="mode-jog"]').click(); doc.querySelector('[data-action="mode-jog"]').click();
await wait(50); await wait(50);
const xBeforeJog = win.webRtcp5AxisSimulation.getState().axisPose.x; const xBeforeJog = win.webRtcp5AxisSimulation.getState().axisPose.x;
@@ -216,11 +479,47 @@
if (win.webRtcp5AxisSimulation.getState().axisPose.x <= xBeforeJog) { if (win.webRtcp5AxisSimulation.getState().axisPose.x <= xBeforeJog) {
throw new Error("JOG X+ did not move the axis"); throw new Error("JOG X+ did not move the axis");
} }
await win.webRtcp5AxisSimulation.restoreSession();
await wait(50);
const restoredState = win.webRtcp5AxisSimulation.getState();
if (restoredState.axisPose.x !== xBeforeJog) {
throw new Error("restoreSession did not restore saved axis pose");
}
if (restoredState.programExecutionSourceMode !== "linuxcnc-interpreter-wasm") {
throw new Error("restoreSession did not restore LinuxCNC interpreter execution source");
}
if (!doc.querySelector('[data-session-persistence="status"]')?.textContent.includes("restored")) {
throw new Error("session restore status did not render");
}
if (win.webRtcp5AxisSimulation.getState().machine.allHomed !== true) {
doc.querySelector('[data-action="mode-manual"]').click();
await wait(50);
doc.querySelector('[data-action="HOME"]').click();
await wait(50);
}
doc.querySelector('[data-action="mode-mdi"]').click(); doc.querySelector('[data-action="mode-mdi"]').click();
const mdiInput = doc.querySelector('[data-action="mdi-command"]');
if (!mdiInput) {
throw new Error("missing MDI command input");
}
mdiInput.value = "G90 X12.5 Y-4 Z1.25 F900";
doc.querySelector('[data-action="mdi-submit"]').click();
await wait(50);
let mdiState = win.webRtcp5AxisSimulation.getState();
if (mdiState.runState !== "mdi") {
throw new Error("MDI action did not update run state");
}
if (mdiState.axisPose.x !== 12.5 || mdiState.axisPose.y !== -4 || mdiState.axisPose.z !== 1.25) {
throw new Error(`MDI input did not move axes: ${JSON.stringify(mdiState.axisPose)}`);
}
if (!doc.querySelector(".mdi-history")?.textContent.includes("G90 X12.5")) {
throw new Error("MDI history did not render executed command");
}
doc.querySelector('[data-action="MDI_RUN"]').click(); doc.querySelector('[data-action="MDI_RUN"]').click();
await wait(50); await wait(50);
if (win.webRtcp5AxisSimulation.getState().runState !== "mdi") { mdiState = win.webRtcp5AxisSimulation.getState();
throw new Error("MDI action did not update run state"); if (mdiState.runState !== "mdi" || mdiState.machine.mdiCommand !== "G90 X12.5 Y-4 Z1.25 F900") {
throw new Error("bottom MDI button did not execute staged command");
} }
doc.querySelector('[data-action="reset"]').click(); doc.querySelector('[data-action="reset"]').click();
await wait(50); await wait(50);
@@ -272,6 +571,8 @@
if (win.webRtcp5AxisSimulation.getState().preview.fullscreen !== true) { if (win.webRtcp5AxisSimulation.getState().preview.fullscreen !== true) {
throw new Error("fullscreen button did not update state"); throw new Error("fullscreen button did not update state");
} }
doc.querySelector('[data-action="mode-manual"]').click();
await wait(50);
doc.querySelector('[data-action="HOME"]').click(); doc.querySelector('[data-action="HOME"]').click();
await wait(50); await wait(50);
const homedState = win.webRtcp5AxisSimulation.getState(); const homedState = win.webRtcp5AxisSimulation.getState();

View File

@@ -0,0 +1,77 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")/../../.." && pwd)"
CHROMIUM="${CHROMIUM:-$(command -v chromium || command -v chromium-browser || command -v google-chrome || command -v google-chrome-stable || true)}"
if [[ -z "$CHROMIUM" ]]; then
echo "missing Chromium-compatible browser; set CHROMIUM=/path/to/browser" >&2
exit 1
fi
npm --prefix "$ROOT_DIR/web-rtcp-5axis-sim-plan/app" run build >/dev/null
TMP_DIR="$(mktemp -d)"
PORT_FILE="$TMP_DIR/port"
SERVER_LOG="$TMP_DIR/server.log"
CHROME_PROFILE="$TMP_DIR/chrome-profile"
mkdir -p "$CHROME_PROFILE"
cleanup() {
if [[ -n "${SERVER_PID:-}" ]]; then
kill "$SERVER_PID" 2>/dev/null || true
wait "$SERVER_PID" 2>/dev/null || true
fi
rm -rf "$TMP_DIR"
}
trap cleanup EXIT
python3 - <<'PY' "$ROOT_DIR" "$PORT_FILE" >"$SERVER_LOG" 2>&1 &
import functools
import http.server
import pathlib
import socketserver
import sys
root = pathlib.Path(sys.argv[1])
port_file = pathlib.Path(sys.argv[2])
handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=str(root))
with socketserver.TCPServer(("127.0.0.1", 0), handler) as httpd:
port_file.write_text(str(httpd.server_address[1]), encoding="ascii")
httpd.serve_forever()
PY
SERVER_PID=$!
for _ in $(seq 1 100); do
[[ -s "$PORT_FILE" ]] && break
sleep 0.05
done
if [[ ! -s "$PORT_FILE" ]]; then
echo "gmoccapy dist browser smoke HTTP server did not start" >&2
cat "$SERVER_LOG" >&2 || true
exit 1
fi
PORT="$(cat "$PORT_FILE")"
APP_PATH="../../app/dist/index.html"
URL="http://127.0.0.1:$PORT/web-rtcp-5axis-sim-plan/tests/browser/gmoccapy_shell_smoke.html?app=$APP_PATH"
OUT="$TMP_DIR/chromium-gmoccapy-dist.out"
"$CHROMIUM" \
--headless=new \
--disable-gpu \
--no-sandbox \
--user-data-dir="$CHROME_PROFILE" \
--virtual-time-budget=10000 \
--dump-dom \
"$URL" >"$OUT" 2>&1
if ! grep -Fq "gmoccapy_shell_smoke=ok" "$OUT"; then
echo "gmoccapy dist browser smoke failed" >&2
sed -n '1,260p' "$OUT" >&2
exit 1
fi
echo "gmoccapy_dist_smoke=ok"

View File

@@ -0,0 +1,73 @@
import assert from "node:assert/strict";
import { createFiveAxisSessionPayload, createMemorySessionStorage } from "../../app/src/runtime/five-axis-session.js";
import { createLinuxCncInterpreterRuntime } from "../../app/src/runtime/linuxcnc-interpreter-runtime.js";
import { createSimulationStore } from "../../app/src/state/store.js";
async function waitForInterpreterExecution(store) {
for (let attempt = 0; attempt < 20; attempt += 1) {
const state = store.getState();
if (!state.interpreterExecutionPending && state.programExecutionSourceMode === "linuxcnc-interpreter-wasm") {
return state;
}
await new Promise((resolve) => setTimeout(resolve, 0));
}
return store.getState();
}
const store = createSimulationStore();
const interpreterRuntime = await createLinuxCncInterpreterRuntime();
store.dispatch({ type: "ATTACH_INTERPRETER_RUNTIME", runtime: interpreterRuntime });
store.dispatch({ type: "TOGGLE_POWER" });
store.dispatch({ type: "HOME" });
store.dispatch({ type: "SET_MODE", mode: "auto" });
store.dispatch({ type: "SET_RTCP", enabled: true });
store.dispatch({
type: "LOAD_PROGRAM",
filename: "session-demo.ngc",
content: [
"G90 G17",
"G0 X0 Y0 Z0",
"G1 X4 Y5 A6 C7 F100",
"M2",
].join("\n"),
});
await waitForInterpreterExecution(store);
store.dispatch({ type: "STEP" });
const beforeSave = store.getState();
const payload = createFiveAxisSessionPayload(beforeSave);
assert.equal(payload.apiName, "web-rtcp-5axis-session-payload");
assert.equal(payload.machineProfile, "xyzac-trt");
assert.equal(payload.programExecution.sourceMode, "linuxcnc-interpreter-wasm");
assert.equal(payload.rtcpState, "on");
assert.equal(payload.programRuntimeFeedback.sourceMode, "linuxcnc-tp-runtime-sample");
assert.equal(payload.programRuntimeFeedback.semanticBoundary, "linuxcnc_tp_run_cycle_feedback_without_hardware");
const storage = createMemorySessionStorage();
const saved = await store.saveSession({ storage });
assert.equal(saved.snapshot.format, "web-rtcp-5axis-session-snapshot");
assert.equal(saved.path, "web-rtcp-5axis-sim-plan/sessions/gmoccapy-web-session/web-rtcp-5axis-session.json");
store.dispatch({ type: "SET_RTCP", enabled: false });
store.dispatch({ type: "STOP" });
store.dispatch({ type: "SET_MODE", mode: "manual" });
store.dispatch({ type: "JOG", axis: "x", direction: 1, increment: 25 });
assert.notEqual(store.getState().rtcpState, beforeSave.rtcpState);
assert.notEqual(store.getState().axisPose.x, beforeSave.axisPose.x);
const restored = await store.restoreSession({ storage });
const afterRestore = store.getState();
assert.equal(restored.path, saved.path);
assert.equal(afterRestore.sessionPersistence.status, "restored");
assert.equal(afterRestore.rtcpState, beforeSave.rtcpState);
assert.equal(afterRestore.kinsType, beforeSave.kinsType);
assert.equal(afterRestore.axisPose.x, beforeSave.axisPose.x);
assert.equal(afterRestore.activeProgram, "session-demo.ngc");
assert.equal(afterRestore.programExecution.sourceMode, "linuxcnc-interpreter-wasm");
assert.equal(afterRestore.programExecution.summary.motionEventCount, beforeSave.programExecution.summary.motionEventCount);
assert.equal(afterRestore.programRuntimeFeedback.semanticBoundary, beforeSave.programRuntimeFeedback.semanticBoundary);
assert.equal(afterRestore.programRuntimeFeedback.axisPose.x, beforeSave.programRuntimeFeedback.axisPose.x);
console.log("five_axis_session_smoke=ok");

View File

@@ -0,0 +1,108 @@
import assert from "node:assert/strict";
import { createFullLinuxCncExecutionBoundary } from "../../app/src/runtime/full-execution-boundary.js";
const blocked = createFullLinuxCncExecutionBoundary({});
assert.equal(blocked.apiName, "web-rtcp-5axis-full-linuxcnc-execution-boundary");
assert.equal(blocked.phase, "blocked");
assert.equal(blocked.promotionAllowed, false);
assert.equal(blocked.fullLinuxCncProgramExecutionReady, false);
assert.equal(blocked.missing.includes("linuxcnc kinematics WASM frame"), true);
assert.equal(blocked.blockers.some((blocker) => blocker.includes("native LinuxCNC task")), true);
const canonicalState = {
machineProfile: "xyzac-trt",
linuxCncBoundaryAdapter: {
linuxCncKinematicsReady: true,
linuxCncInterpreterReady: true,
},
rtcpFrame: {
semanticBoundary: "linuxcnc_kinematics_wasm_c_abi",
readiness: { linuxCncKinematicsReady: true },
},
interpreterRuntimeReadiness: {
loaded: true,
semanticBoundary: "linuxcnc_interpreter_wasm_canonical_events",
},
programExecutionSourceMode: "linuxcnc-interpreter-wasm",
programExecution: {
sourceMode: "linuxcnc-interpreter-wasm",
summary: {
motionEventCount: 3,
canonicalEventCount: 8,
},
},
};
const canonical = createFullLinuxCncExecutionBoundary(canonicalState);
assert.equal(canonical.readyForUiSimulation, true);
assert.equal(canonical.machineFileBackedRemapReady, false);
assert.equal(canonical.semanticBoundary, "linuxcnc_interpreter_canonical_ready_planner_task_hal_blocked");
assert.equal(canonical.satisfied.includes("canonical-motion-events"), true);
assert.equal(canonical.plannerRuntimeReady, false);
assert.equal(canonical.missing.includes("machine-file backed five-axis remap run"), true);
const canonicalWithPlanner = createFullLinuxCncExecutionBoundary({
...canonicalState,
programExecution: {
...canonicalState.programExecution,
plannerTiming: {
plannerRuntimeReady: true,
semanticBoundary: "linuxcnc_tp_queue_runtime_timing_from_canonical_motion",
},
summary: {
...canonicalState.programExecution.summary,
plannerRuntimeReady: true,
},
},
});
assert.equal(canonicalWithPlanner.plannerRuntimeReady, true);
assert.equal(canonicalWithPlanner.satisfied.includes("linuxcnc-tp-queue-runtime-timing"), true);
assert.equal(canonicalWithPlanner.nativeTaskReady, false);
assert.equal(canonicalWithPlanner.nativeHalSyncReady, false);
const remap = createFullLinuxCncExecutionBoundary({
...canonicalState,
programExecution: {
...canonicalState.programExecution,
plannerTiming: {
plannerRuntimeReady: true,
semanticBoundary: "linuxcnc_tp_queue_runtime_timing_from_canonical_motion",
},
summary: {
...canonicalState.programExecution.summary,
plannerRuntimeReady: true,
},
},
machineFileStaging: {
status: "staged",
fileCount: 12,
},
machineFileExecution: {
sourceMode: "linuxcnc-machine-file-remap-wasm",
summary: {
machineFileExecutionReady: true,
},
resultText: [
"fiveaxis_ini_open=1",
"fiveaxis_remaps_ready=1",
"fiveaxis_file_reached_exit=1",
"fiveaxis_hal_switchkins: rc=0 found=1 value=0",
].join("\n"),
},
});
assert.equal(remap.machineFileBackedRemapReady, true);
assert.equal(remap.remapRuntimeReady, true);
assert.equal(remap.halSwitchkinsEvidenceReady, true);
assert.equal(remap.plannerRuntimeReady, true);
assert.equal(remap.nativeTaskReady, false);
assert.equal(remap.nativeHalSyncReady, false);
assert.equal(remap.fullLinuxCncProgramExecutionReady, false);
assert.equal(remap.promotionAllowed, false);
assert.equal(remap.semanticBoundary, "linuxcnc_machine_file_remap_ready_planner_task_hal_blocked");
assert.equal(remap.satisfied.includes("fiveaxis-remap-machine-file-run"), true);
assert.equal(remap.evidence.machineFileFlags.length, 3);
console.log("full_execution_boundary_smoke=ok");

View File

@@ -0,0 +1,109 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import {
createLinuxCncInterpSdk,
createLinuxCncKinematicsSdk,
planSimConfigStaging,
} from "../../../wasm-port/runtime/sdk/src/index.js";
import { parseLinuxCncIni } from "../../app/src/runtime/linuxcnc-ini-runtime.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const workspaceRoot = resolve(__dirname, "../../..");
const wasmPortRoot = resolve(workspaceRoot, "wasm-port");
const vendorRoot = resolve(wasmPortRoot, "vendor/linuxcnc");
const trtConfigRel = "configs/sim/axis/vismach/5axis/table-rotary-tilting";
function near(actual, expected, tolerance = 1e-7) {
return Math.abs(actual - expected) <= tolerance;
}
function assertPoseComponent(pose, key, expected, label) {
assert.equal(near(Number(pose[key] ?? 0), expected), true, `${label}.${key}: ${pose[key]} != ${expected}`);
}
async function verifyTrtKinematics(moduleId, joints, expectedAxis) {
const wasmFile = `linuxcnc_${moduleId.replaceAll("-", "_")}_kinematics.wasm`;
const kins = await createLinuxCncKinematicsSdk({
moduleId,
moduleOptions: {
wasmBinary: readFileSync(resolve(wasmPortRoot, "build/wasm/kinematics", wasmFile)),
print() {},
printErr() {},
},
});
assert.equal(kins.apiName, "linuxcnc-kinematics-wasm-sdk");
assert.equal(kins.switchable(), 1);
assert.equal(kins.switchKinematics(0), 0);
const forward = kins.forward(joints);
assert.equal(forward.rc, 0, `${moduleId} forward`);
const inverse = kins.inverse(forward.pose, 5, { seedJoints: joints });
assert.equal(inverse.rc, 0, `${moduleId} inverse`);
inverse.joints.slice(0, 5).forEach((joint, index) => {
assert.equal(near(joint, joints[index]), true, `${moduleId} inverse joint ${index}`);
});
assert.equal(kins.switchKinematics(1), 0);
const identity = kins.forward(joints);
assert.equal(identity.rc, 0, `${moduleId} identity forward`);
assertPoseComponent(identity.pose, "x", joints[0], `${moduleId} identity`);
assertPoseComponent(identity.pose, "y", joints[1], `${moduleId} identity`);
assertPoseComponent(identity.pose, "z", joints[2], `${moduleId} identity`);
assertPoseComponent(identity.pose, expectedAxis, joints[3], `${moduleId} identity`);
assertPoseComponent(identity.pose, "c", joints[4], `${moduleId} identity`);
}
await verifyTrtKinematics("xyzac-trt", [10, 20, 30, 25, 40], "a");
await verifyTrtKinematics("xyzbc-trt", [10, 20, 30, 35, 40], "b");
const manifestText = readFileSync(resolve(wasmPortRoot, "tools/source-manifest.txt"), "utf8");
for (const iniFile of ["xyzac-trt.ini", "xyzbc-trt.ini"]) {
const iniText = readFileSync(resolve(vendorRoot, trtConfigRel, iniFile), "utf8");
const iniConfig = parseLinuxCncIni(iniText, {
path: `${trtConfigRel}/${iniFile}`,
profileId: iniFile.startsWith("xyzbc") ? "xyzbc-trt" : "xyzac-trt",
});
assert.equal(iniConfig.validation.ready, true, `${iniFile} INI ready`);
assert.equal(iniConfig.kinematics.name.endsWith("-trt-kins"), true, `${iniFile} kins`);
assert.deepEqual(iniConfig.halui.mdiCommands, ["M429", "M428", "M430"], `${iniFile} HALUI MDI`);
const plan = planSimConfigStaging({
manifestText,
machineRel: `axis/vismach/5axis/table-rotary-tilting`,
iniFile,
iniText,
});
const staged = plan.files.map((file) => file.sourceRel);
assert.equal(staged.includes(`${trtConfigRel}/${iniFile}`), true, `${iniFile} staged`);
assert.equal(staged.some((file) => file.endsWith("/remap_subs/428remap.ngc")), true, `${iniFile} M428 remap staged`);
assert.equal(staged.some((file) => file.endsWith("/remap_subs/429remap.ngc")), true, `${iniFile} M429 remap staged`);
assert.equal(staged.some((file) => file.endsWith("/remap_subs/430remap.ngc")), true, `${iniFile} M430 remap staged`);
assert.equal(staged.some((file) => file.endsWith(".tbl")), true, `${iniFile} tool table staged`);
}
const interp = await createLinuxCncInterpSdk({
wasmBinary: readFileSync(resolve(wasmPortRoot, "build/wasm/core/linuxcnc_interp.wasm")),
print() {},
printErr() {},
});
const directFiveAxisProgram = [
"G90 G17",
"G0 X0 Y0 Z0 A0 C0",
"G1 X10 Y2 Z-1 A15 C30 F120",
"G1 X0 Y0 Z0 A0 C0 F120",
"M2",
"",
].join("\n");
const output = interp.runProgram(directFiveAxisProgram);
assert.equal(output.includes("canon_event=STRAIGHT_TRAVERSE"), true);
assert.equal(output.includes("canon_event=STRAIGHT_FEED"), true);
assert.equal(output.includes("a=15"), true);
assert.equal(output.includes("c=30"), true);
console.log("full_linuxcnc_5axis_source_node_smoke=ok");

View File

@@ -0,0 +1,57 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import {
applyIniConfigToProfile,
parseLinuxCncIni,
} from "../../app/src/runtime/linuxcnc-ini-runtime.js";
import { xyzacTrtProfile } from "../../app/src/profiles/xyzac-trt.js";
import { xyzbcTrtProfile } from "../../app/src/profiles/xyzbc-trt.js";
const xyzacIniText = await readFile(
new URL("../../../wasm-port/vendor/linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini", import.meta.url),
"utf8",
);
const xyzbcIniText = await readFile(
new URL("../../../wasm-port/vendor/linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzbc-trt.ini", import.meta.url),
"utf8",
);
const xyzacIni = parseLinuxCncIni(xyzacIniText, {
path: xyzacTrtProfile.iniPath,
profileId: xyzacTrtProfile.id,
});
assert.equal(xyzacIni.apiName, "web-rtcp-5axis-linuxcnc-ini-config");
assert.equal(xyzacIni.validation.ready, true);
assert.equal(xyzacIni.machineName, "sim-xyzac-trt-kins (switchkins)");
assert.equal(xyzacIni.kinematics.name, "xyzac-trt-kins");
assert.equal(xyzacIni.kinematicsParameters.sparm, "identityfirst");
assert.equal(xyzacIni.kinematicsParameters.joints, 5);
assert.equal(xyzacIni.traj.coordinates, "XYZAC");
assert.equal(xyzacIni.axisLimits.A.max, 50);
assert.equal(xyzacIni.jointConfig[3].axis, "A");
assert.equal(xyzacIni.jointConfig[4].max, 36000);
assert.deepEqual(xyzacIni.halui.mdiCommands, ["M429", "M428", "M430"]);
assert.equal(xyzacIni.rs274ngc.remaps.map((remap) => remap.code).join(","), "M428,M429,M430");
assert.equal(xyzacIni.hal.initialSets.some((set) => set.pin === "y-offset" && set.value === 20), true);
const derivedXyzac = applyIniConfigToProfile(xyzacTrtProfile, xyzacIni);
assert.equal(derivedXyzac.machineName, xyzacIni.machineName);
assert.equal(derivedXyzac.traj.coordinates, "XYZAC");
assert.equal(derivedXyzac.axisLimits.X.min, -200);
assert.equal(derivedXyzac.jointConfig.length, 5);
assert.equal(derivedXyzac.linuxCncIniConfig.path.endsWith("xyzac-trt.ini"), true);
const xyzbcIni = parseLinuxCncIni(xyzbcIniText, {
path: xyzbcTrtProfile.iniPath,
profileId: xyzbcTrtProfile.id,
});
assert.equal(xyzbcIni.validation.ready, true);
assert.equal(xyzbcIni.kinematics.name, "xyzbc-trt-kins");
assert.equal(xyzbcIni.kinematicsModuleId, "xyzbc-trt");
assert.equal(xyzbcIni.traj.coordinates, "XYZBC");
assert.equal(xyzbcIni.axisLimits.B.max, 36000);
assert.equal(xyzbcIni.jointConfig[3].axis, "B");
assert.equal(xyzbcIni.kinematicsParameters.switchkinsTypes[1].webKinsType, "tcp-xyzbc");
console.log("linuxcnc_ini_runtime_smoke=ok");

View File

@@ -0,0 +1,80 @@
import assert from "node:assert/strict";
import { createLinuxCncInterpreterRuntime } from "../../app/src/runtime/linuxcnc-interpreter-runtime.js";
const runtime = await createLinuxCncInterpreterRuntime();
const readiness = runtime.readiness();
assert.equal(runtime.apiName, "web-rtcp-5axis-linuxcnc-interpreter-runtime");
assert.equal(runtime.loaded, true);
assert.equal(runtime.sourceMode, "linuxcnc-interpreter-wasm");
assert.equal(runtime.semanticBoundary, "linuxcnc_interpreter_wasm_canonical_events");
assert.equal(readiness.loaded, true);
assert.equal(readiness.runProgramReady, true);
assert.equal(readiness.remapRuntimeReady, false);
assert.equal(readiness.plannerRuntimeReady, true);
assert.equal(readiness.plannerSemanticBoundary, "linuxcnc_tp_queue_runtime_timing_from_canonical_motion");
const programText = [
"G90 G17",
"G0 X0 Y0 Z0",
"G1 X10 Y2 F100",
"G1 X12 Y4 A5 C7",
"M2",
].join("\n");
const execution = runtime.runProgram(programText);
assert.equal(execution.apiName, "web-rtcp-5axis-linuxcnc-interpreter-program-execution");
assert.equal(execution.sourceMode, "linuxcnc-interpreter-wasm");
assert.equal(execution.semanticBoundary, "linuxcnc_interpreter_wasm_canonical_events");
assert.equal(execution.summary.ready, true);
assert.equal(execution.summary.motionEventCount >= 3, true);
assert.equal(execution.summary.canonicalEventCount > execution.summary.motionEventCount, true);
assert.equal(execution.summary.motionTypes.includes("STRAIGHT_TRAVERSE"), true);
assert.equal(execution.summary.motionTypes.includes("STRAIGHT_FEED"), true);
assert.equal(execution.summary.finalAxes.x, 12);
assert.equal(execution.summary.finalAxes.y, 4);
assert.equal(execution.summary.finalAxes.a, 5);
assert.equal(execution.summary.finalAxes.c, 7);
assert.equal(execution.motion[2].feedRate, 100);
assert.equal(execution.summary.remapRuntimeReady, false);
assert.equal(execution.summary.plannerRuntimeReady, true);
assert.equal(execution.plannerTiming.semanticBoundary, "linuxcnc_tp_queue_runtime_timing_from_canonical_motion");
assert.equal(execution.plannerTiming.totalSeconds > 0, true);
assert.equal(execution.plannerTiming.motionCount, execution.motion.length);
assert.equal(execution.summary.fullLinuxCncProgramExecutionReady, false);
const switchkinsProgramText = [
"G90 G17",
"M428",
"G0 X0 Y0 Z0 A0 C0",
"G1 X10 Y2 Z-1 A15 C30 F120",
"M429",
"G1 X0 Y0 Z0 A0 C0 F120",
"M2",
].join("\n");
const switchkinsExecution = runtime.runProgram(switchkinsProgramText);
assert.equal(switchkinsExecution.summary.ready, true);
assert.equal(switchkinsExecution.summary.motionEventCount, 3);
assert.equal(switchkinsExecution.summary.switchkinsEventCount, 2);
assert.deepEqual(switchkinsExecution.summary.switchkinsCodes, ["M428", "M429"]);
assert.equal(
switchkinsExecution.summary.switchkinsRemapBoundary,
"linuxcnc_switchkins_remap_mcode_preserved_web_runtime_applied",
);
assert.equal(switchkinsExecution.switchkinsEvents[0].switchkinsType, 1);
assert.equal(switchkinsExecution.switchkinsEvents[1].switchkinsType, 0);
assert.equal(switchkinsExecution.motion[0].kinsType, "tcp");
assert.equal(switchkinsExecution.motion[1].axes.a, 15);
assert.equal(switchkinsExecution.motion[1].axes.c, 30);
assert.equal(switchkinsExecution.motion[1].switchkinsCode, "M428");
assert.equal(switchkinsExecution.motion[2].kinsType, "identity");
assert.equal(switchkinsExecution.motion[2].switchkinsCode, "M429");
assert.equal(switchkinsExecution.summary.remapRuntimeReady, false);
assert.equal(switchkinsExecution.summary.plannerRuntimeReady, true);
assert.equal(switchkinsExecution.plannerTiming.totalSeconds > 0, true);
assert.equal(switchkinsExecution.summary.fullLinuxCncProgramExecutionReady, false);
console.log("linuxcnc_interpreter_runtime_smoke=ok");

View File

@@ -11,8 +11,10 @@ assert.equal(runtime.moduleId, "xyzac-trt");
assert.equal(runtime.loaded, true); assert.equal(runtime.loaded, true);
assert.equal(runtime.sourceMode, "source-derived-kinematics-wasm"); assert.equal(runtime.sourceMode, "source-derived-kinematics-wasm");
assert.equal(runtime.semanticBoundary, "linuxcnc_kinematics_wasm_c_abi"); assert.equal(runtime.semanticBoundary, "linuxcnc_kinematics_wasm_c_abi");
assert.equal(runtime.executionContext, "direct");
assert.equal(runtime.wasmFile, "linuxcnc_xyzac_trt_kinematics.wasm"); assert.equal(runtime.wasmFile, "linuxcnc_xyzac_trt_kinematics.wasm");
assert.equal(readiness.loaded, true); assert.equal(readiness.loaded, true);
assert.equal(readiness.executionContext, "direct");
assert.equal(readiness.supportedModules.includes("xyzac-trt"), true); assert.equal(readiness.supportedModules.includes("xyzac-trt"), true);
assert.equal(runtime.switchRc, 0); assert.equal(runtime.switchRc, 0);

View File

@@ -0,0 +1,128 @@
import assert from "node:assert/strict";
import { createMemorySessionStorage } from "../../app/src/runtime/five-axis-session.js";
import { createLinuxCncInterpreterRuntime } from "../../app/src/runtime/linuxcnc-interpreter-runtime.js";
import {
createMachineFileStagingPlan,
selectMachineFileProgram,
stageProfileMachineFiles,
} from "../../app/src/runtime/linuxcnc-machine-file-staging.js";
import { getFiveAxisProfile } from "../../app/src/profiles/index.js";
import { createSimulationStore } from "../../app/src/state/store.js";
async function waitForMachineFileExecution(store) {
for (let attempt = 0; attempt < 20; attempt += 1) {
const state = store.getState();
if (!state.interpreterExecutionPending && state.machineFileExecution) {
return state;
}
await new Promise((resolve) => setTimeout(resolve, 0));
}
return store.getState();
}
const profile = getFiveAxisProfile("xyzac-trt");
const storage = createMemorySessionStorage();
const plan = await createMachineFileStagingPlan({ profile });
assert.equal(plan.apiName, "web-rtcp-5axis-machine-file-staging-plan");
assert.equal(plan.profileId, "xyzac-trt");
assert.equal(plan.semanticBoundary, "linuxcnc_sim_config_file_staging_plan_only");
assert.equal(plan.files.some((file) => file.sourceRel.endsWith("xyzac-trt.ini")), true);
assert.equal(plan.files.some((file) => file.sourceRel.endsWith("xyzac-trt.tbl")), true);
assert.equal(plan.files.some((file) => file.sourceRel.endsWith("remap_subs/428remap.ngc")), true);
assert.equal(plan.files.some((file) => file.sourceRel.endsWith("remap_subs/429remap.ngc")), true);
assert.equal(plan.files.some((file) => file.sourceRel.endsWith("demos/xyzac_switchkins.ngc")), true);
assert.equal(plan.summary.remapFileCount >= 3, true);
assert.equal(plan.summary.demoFileCount >= 1, true);
const staged = await stageProfileMachineFiles(profile, { storage });
assert.equal(staged.save.apiName, "web-rtcp-5axis-machine-file-staging-save");
assert.equal(staged.save.status, "saved");
assert.equal(staged.save.fileCount, staged.plan.files.length);
assert.equal(staged.save.semanticBoundary, "opfs_machine_file_text_staging_only");
assert.equal(staged.save.summary.kinds.remap >= 3, true);
assert.equal(staged.save.gcodeSources.some((source) => source.filename === "impeller-7bl-xyzac.ngc"), true);
assert.equal(staged.save.gcodeSources.some((source) => source.filename === "boat-xyzac.ngc"), true);
assert.equal(staged.save.files.every((file) => file.opfsPath.startsWith("web-rtcp-5axis-sim-plan/machines/xyzac-trt/")), true);
assert.throws(
() => selectMachineFileProgram(staged.plan, staged.save, "configs/sim/axis/vismach/5axis/table-rotary-tilting/remap_subs/428remap.ngc"),
/must come from LinuxCNC source demos/,
);
const iniOpfsPath = staged.save.files.find((file) => file.sourceRel.endsWith("xyzac-trt.ini")).opfsPath;
assert.equal(storage.files.get(iniOpfsPath).includes("KINEMATICS = xyzac-trt-kins"), true);
const store = createSimulationStore();
store.dispatch({
type: "ATTACH_INTERPRETER_RUNTIME",
runtime: await createLinuxCncInterpreterRuntime(),
});
const storeStage = await store.stageMachineFiles({ storage });
const state = store.getState();
assert.equal(storeStage.save.fileCount, staged.save.fileCount);
assert.equal(state.machineFileStaging.status, "staged");
assert.equal(state.machineFileStaging.profileId, "xyzac-trt");
assert.equal(state.machineFileStaging.fileCount, staged.save.fileCount);
assert.equal(state.machineFileStaging.save.summary.kinds.remap >= 3, true);
assert.equal(state.machineFileStaging.gcodeSources.length >= 4, true);
assert.equal(state.machineFileStaging.selectedGcodeSourceRel, null);
store.dispatch({ type: "RUN_MACHINE_FILE_PROGRAM" });
assert.equal(
store.getState().operatorMessage,
"machine-file run blocked: select a LinuxCNC source-directory 5-axis G-code program",
);
store.dispatch({
type: "LOAD_LINUXCNC_GCODE_SOURCE",
sourceRel: "operator-demo.ngc",
});
assert.equal(
store.getState().operatorMessage,
"LinuxCNC G-code source not staged: operator-demo.ngc",
);
store.dispatch({
type: "LOAD_LINUXCNC_GCODE_SOURCE",
sourceRel: "configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/impeller-7bl-xyzac.ngc",
});
await new Promise((resolve) => setTimeout(resolve, 0));
const sourceState = store.getState();
assert.equal(sourceState.programSource, "linuxcnc-vendored-5axis-gcode");
assert.equal(sourceState.activeProgram.endsWith("impeller-7bl-xyzac.ngc"), true);
assert.equal(sourceState.programLines[0].includes("Impeller 5-axis"), true);
assert.equal(sourceState.machineFileStaging.selectedGcodeSourceRel.endsWith("impeller-7bl-xyzac.ngc"), true);
assert.equal(sourceState.machineFileStaging.plan.wasmProgramPath.endsWith("/demos/impeller-7bl-xyzac.ngc"), true);
store.dispatch({ type: "RUN_MACHINE_FILE_PROGRAM" });
const runState = await waitForMachineFileExecution(store);
assert.equal(runState.machineFileExecution.sourceMode, "linuxcnc-machine-file-remap-wasm");
assert.equal(runState.machineFileExecution.semanticBoundary, "linuxcnc_fiveaxis_remap_wasm_machine_file_execution");
assert.equal(runState.machineFileExecution.summary.machineFileExecutionReady, true);
assert.equal(runState.machineFileExecution.summary.remapRuntimeReady, true);
assert.equal(runState.machineFileExecution.summary.plannerRuntimeReady, false);
assert.equal(runState.machineFileExecution.summary.fullLinuxCncProgramExecutionReady, false);
assert.equal(runState.machineFileExecution.resultText.includes("fiveaxis_ini_open=1"), true);
assert.equal(runState.machineFileExecution.resultText.includes("fiveaxis_remaps_ready=1"), true);
assert.equal(runState.machineFileExecution.resultText.includes("fiveaxis_file_reached_exit=1"), true);
assert.equal(runState.machineFileExecution.machineFilePlan.selectedProgramFilename, "impeller-7bl-xyzac.ngc");
assert.equal(runState.machineFileExecution.machineFilePlan.wasmProgramPath.endsWith("/demos/impeller-7bl-xyzac.ngc"), true);
assert.equal(runState.fullExecutionBoundary.apiName, "web-rtcp-5axis-full-linuxcnc-execution-boundary");
assert.equal(runState.fullExecutionBoundary.machineFileBackedRemapReady, true);
assert.equal(runState.fullExecutionBoundary.remapRuntimeReady, true);
assert.equal(runState.fullExecutionBoundary.halSwitchkinsEvidenceReady, true);
assert.equal(runState.fullExecutionBoundary.plannerRuntimeReady, false);
assert.equal(runState.fullExecutionBoundary.nativeTaskReady, false);
assert.equal(runState.fullExecutionBoundary.nativeHalSyncReady, false);
assert.equal(runState.fullExecutionBoundary.fullLinuxCncProgramExecutionReady, false);
assert.equal(runState.fullExecutionBoundary.promotionAllowed, false);
assert.equal(
runState.fullExecutionBoundary.semanticBoundary,
"linuxcnc_machine_file_remap_ready_planner_task_hal_blocked",
);
assert.equal(runState.fullExecutionBoundary.satisfied.includes("fiveaxis-remap-machine-file-run"), true);
assert.equal(runState.fullExecutionBoundary.satisfied.includes("switchkins-hal-bridge-evidence"), true);
assert.equal(runState.fullExecutionBoundary.blockers.some((blocker) => blocker.includes("trajectory planner")), true);
console.log("machine_file_staging_smoke=ok");

View File

@@ -1,6 +1,7 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { xyzacTrtProfile } from "../../app/src/profiles/xyzac-trt.js"; import { xyzacTrtProfile } from "../../app/src/profiles/xyzac-trt.js";
import { getFiveAxisProfile, fiveAxisProfiles } from "../../app/src/profiles/index.js";
import { createPyvcpHalBindingSummary, xyzacTrtPyvcpPanelSchema } from "../../app/src/panel-schema/xyzac-trt-pyvcp.js"; import { createPyvcpHalBindingSummary, xyzacTrtPyvcpPanelSchema } from "../../app/src/panel-schema/xyzac-trt-pyvcp.js";
import { createLinuxCncBoundaryAdapter, createLinuxCncBoundaryReadiness } from "../../app/src/runtime/linuxcnc-boundary-adapter.js"; import { createLinuxCncBoundaryAdapter, createLinuxCncBoundaryReadiness } from "../../app/src/runtime/linuxcnc-boundary-adapter.js";
import { createLinuxCncKinematicsRuntime } from "../../app/src/runtime/linuxcnc-kinematics-runtime.js"; import { createLinuxCncKinematicsRuntime } from "../../app/src/runtime/linuxcnc-kinematics-runtime.js";
@@ -29,6 +30,20 @@ assert.equal(xyzacTrtProfile.halPins.includes("motion.switchkins-type"), true);
assert.equal(xyzacTrtProfile.halPins.includes("xyzac-trt-kins.tool-offset"), true); assert.equal(xyzacTrtProfile.halPins.includes("xyzac-trt-kins.tool-offset"), true);
assert.equal(xyzacTrtProfile.promotionAllowed, false); assert.equal(xyzacTrtProfile.promotionAllowed, false);
assert.equal(xyzacTrtProfile.linuxCncKinematicsReady, false); assert.equal(xyzacTrtProfile.linuxCncKinematicsReady, false);
assert.deepEqual(fiveAxisProfiles.map(({ id }) => id), ["xyzac-trt", "xyzbc-trt"]);
const xyzbcTrtProfile = getFiveAxisProfile("xyzbc-trt");
assert.equal(xyzbcTrtProfile.id, "xyzbc-trt");
assert.equal(xyzbcTrtProfile.iniPath.endsWith("xyzbc-trt.ini"), true);
assert.deepEqual(xyzbcTrtProfile.coordinates, ["X", "Y", "Z", "B", "C"]);
assert.equal(xyzbcTrtProfile.kinematics, "xyzbc-trt-kins");
assert.equal(xyzbcTrtProfile.kinematicsModuleId, "xyzbc-trt");
assert.equal(xyzbcTrtProfile.machineName, "sim-xyzbc-trt-kins (switchkins)");
assert.equal(xyzbcTrtProfile.traj.coordinates, "XYZBC");
assert.equal(xyzbcTrtProfile.axisLimits.B.max, 36000);
assert.equal(xyzbcTrtProfile.hal.halcmd.feedbackNets.some((net) => net.target === "xyzbc-trt-gui.tilt-b"), true);
assert.equal(xyzbcTrtProfile.halPins.includes("xyzbc-trt-kins.x-offset"), true);
assert.equal(xyzbcTrtProfile.panelSchema.id, "xyzbc-trt-switchkins-pyvcp");
const sourceSummary = createProfileSourceReferenceSummary("xyzac-trt"); const sourceSummary = createProfileSourceReferenceSummary("xyzac-trt");
assert.equal(sourceSummary.referenceCount >= 8, true); assert.equal(sourceSummary.referenceCount >= 8, true);
@@ -39,6 +54,11 @@ assert.equal(sourceSummary.semanticBoundary, "profile_source_map_only_not_runtim
assert.ok(sourceSummary.references.some((reference) => reference.path.endsWith("xyzac-trt.ini"))); assert.ok(sourceSummary.references.some((reference) => reference.path.endsWith("xyzac-trt.ini")));
assert.ok(sourceSummary.references.some((reference) => reference.path.endsWith("trtfuncs.c"))); assert.ok(sourceSummary.references.some((reference) => reference.path.endsWith("trtfuncs.c")));
const xyzbcSourceSummary = createProfileSourceReferenceSummary("xyzbc-trt");
assert.equal(xyzbcSourceSummary.referenceCount >= 7, true);
assert.ok(xyzbcSourceSummary.references.some((reference) => reference.path.endsWith("xyzbc-trt.ini")));
assert.ok(xyzbcSourceSummary.references.some((reference) => reference.path.endsWith("xyzbc-trt-kins.c")));
const panelSummary = createPyvcpHalBindingSummary(xyzacTrtPyvcpPanelSchema); const panelSummary = createPyvcpHalBindingSummary(xyzacTrtPyvcpPanelSchema);
assert.equal(panelSummary.schemaId, "xyzac-trt-switchkins-pyvcp"); assert.equal(panelSummary.schemaId, "xyzac-trt-switchkins-pyvcp");
assert.equal(panelSummary.controlCount, 5); assert.equal(panelSummary.controlCount, 5);

View File

@@ -1,8 +1,22 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { createLinuxCncInterpreterRuntime } from "../../app/src/runtime/linuxcnc-interpreter-runtime.js";
import { createLinuxCncKinematicsRuntime } from "../../app/src/runtime/linuxcnc-kinematics-runtime.js"; import { createLinuxCncKinematicsRuntime } from "../../app/src/runtime/linuxcnc-kinematics-runtime.js";
import { parseLinuxCncIni } from "../../app/src/runtime/linuxcnc-ini-runtime.js";
import { buildRtcpFrame } from "../../app/src/runtime/rtcp-frame.js"; import { buildRtcpFrame } from "../../app/src/runtime/rtcp-frame.js";
import { createSimulationStore } from "../../app/src/state/store.js"; import { createSimulationStore } from "../../app/src/state/store.js";
import { readFile } from "node:fs/promises";
async function waitForInterpreterExecution(store) {
for (let attempt = 0; attempt < 20; attempt += 1) {
const state = store.getState();
if (!state.interpreterExecutionPending && state.programExecutionSourceMode === "linuxcnc-interpreter-wasm") {
return state;
}
await new Promise((resolve) => setTimeout(resolve, 0));
}
return store.getState();
}
const identityFrame = buildRtcpFrame({ const identityFrame = buildRtcpFrame({
axisPose: { x: 43, y: -32.15, z: -11.306, a: 0, b: 0, c: 0 }, axisPose: { x: 43, y: -32.15, z: -11.306, a: 0, b: 0, c: 0 },
@@ -34,6 +48,7 @@ assert.ok(Number.isFinite(tcpFrame.toolAxisVector.z));
assert.equal(tcpFrame.jointPose.length, 5); assert.equal(tcpFrame.jointPose.length, 5);
const runtime = await createLinuxCncKinematicsRuntime({ moduleId: "xyzac-trt" }); const runtime = await createLinuxCncKinematicsRuntime({ moduleId: "xyzac-trt" });
const interpreterRuntime = await createLinuxCncInterpreterRuntime();
const linuxCncKinematicsResult = runtime.frameForJoints([10, 20, 30, 25, 40]); const linuxCncKinematicsResult = runtime.frameForJoints([10, 20, 30, 25, 40]);
const linuxCncFrame = buildRtcpFrame({ const linuxCncFrame = buildRtcpFrame({
axisPose: { x: 10, y: 20, z: 30, a: 25, b: 0, c: 40 }, axisPose: { x: 10, y: 20, z: 30, a: 25, b: 0, c: 40 },
@@ -65,6 +80,37 @@ assert.equal(fixtureInitialStore.getState().linuxCncBoundaryAdapter.profileSumma
assert.equal(fixtureInitialStore.getState().linuxCncBoundaryAdapter.profileSummary.coordinates, "XYZAC"); assert.equal(fixtureInitialStore.getState().linuxCncBoundaryAdapter.profileSummary.coordinates, "XYZAC");
assert.equal(fixtureInitialStore.getState().linuxCncBoundaryReadiness.ready, false); assert.equal(fixtureInitialStore.getState().linuxCncBoundaryReadiness.ready, false);
assert.equal(fixtureInitialStore.getState().linuxCncBoundaryReadiness.promotionAllowed, false); assert.equal(fixtureInitialStore.getState().linuxCncBoundaryReadiness.promotionAllowed, false);
assert.equal(fixtureInitialStore.getState().fullExecutionBoundary.fullLinuxCncProgramExecutionReady, false);
assert.equal(fixtureInitialStore.getState().fullExecutionBoundary.promotionAllowed, false);
assert.equal(fixtureInitialStore.getState().fullExecutionBoundary.phase, "blocked");
assert.equal(fixtureInitialStore.getState().linuxCncTaskPolicy.semanticBoundary, "linuxcnc_task_state_mode_command_gate");
assert.equal(
fixtureInitialStore.getState().linuxCncTaskPolicy.sourceReferences.some((reference) => reference.path === "linuxcnc/src/emc/task/emctaskmain.cc"),
true,
);
assert.equal(fixtureInitialStore.getState().linuxCncTaskPolicy.canRunAuto, false);
const xyzacIniText = await readFile(
new URL("../../../wasm-port/vendor/linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini", import.meta.url),
"utf8",
);
const xyzacIniConfig = parseLinuxCncIni(xyzacIniText, {
path: fixtureInitialStore.getState().profile.iniPath,
profileId: fixtureInitialStore.getState().machineProfile,
});
fixtureInitialStore.dispatch({ type: "ATTACH_INI_CONFIG", iniConfig: xyzacIniConfig });
assert.equal(fixtureInitialStore.getState().iniConfigReadiness.loaded, true);
assert.equal(fixtureInitialStore.getState().iniConfigReadiness.ready, true);
assert.equal(fixtureInitialStore.getState().profile.axisLimits.X.max, 200);
fixtureInitialStore.dispatch({ type: "TOGGLE_POWER" });
fixtureInitialStore.dispatch({ type: "HOME" });
fixtureInitialStore.dispatch({ type: "SET_MODE", mode: "mdi" });
fixtureInitialStore.dispatch({ type: "RUN_MDI", command: "G90 X999 Y-999 Z999 A999 C99999" });
assert.equal(fixtureInitialStore.getState().axisPose.x, 200);
assert.equal(fixtureInitialStore.getState().axisPose.y, -100);
assert.equal(fixtureInitialStore.getState().axisPose.z, 120);
assert.equal(fixtureInitialStore.getState().axisPose.a, 50);
assert.equal(fixtureInitialStore.getState().axisPose.c, 36000);
const kinematicsStore = createSimulationStore(); const kinematicsStore = createSimulationStore();
kinematicsStore.dispatch({ type: "ATTACH_KINEMATICS_RUNTIME", runtime }); kinematicsStore.dispatch({ type: "ATTACH_KINEMATICS_RUNTIME", runtime });
@@ -74,6 +120,9 @@ assert.equal(kinematicsState.linuxCncBoundaryAdapter.interpreterRuntimeReady, fa
assert.equal(kinematicsState.linuxCncBoundaryReadiness.ready, true); assert.equal(kinematicsState.linuxCncBoundaryReadiness.ready, true);
assert.equal(kinematicsState.linuxCncBoundaryReadiness.fullLinuxCncProgramExecutionReady, false); assert.equal(kinematicsState.linuxCncBoundaryReadiness.fullLinuxCncProgramExecutionReady, false);
assert.equal(kinematicsState.linuxCncBoundaryReadiness.missing.includes("interpreter/remap runtime"), true); assert.equal(kinematicsState.linuxCncBoundaryReadiness.missing.includes("interpreter/remap runtime"), true);
assert.equal(kinematicsState.fullExecutionBoundary.satisfied.includes("linuxcnc-kinematics-wasm"), true);
assert.equal(kinematicsState.fullExecutionBoundary.missing.includes("linuxcnc interpreter WASM runtime"), true);
assert.equal(kinematicsState.kinematicsExecutionContext, "direct");
assert.equal(kinematicsState.rtcpFrame.sourceMode, "source-derived-kinematics-wasm"); assert.equal(kinematicsState.rtcpFrame.sourceMode, "source-derived-kinematics-wasm");
assert.equal(kinematicsState.rtcpFrame.semanticBoundary, "linuxcnc_kinematics_wasm_c_abi"); assert.equal(kinematicsState.rtcpFrame.semanticBoundary, "linuxcnc_kinematics_wasm_c_abi");
assert.equal(kinematicsState.rtcpFrame.readiness.linuxCncKinematicsReady, true); assert.equal(kinematicsState.rtcpFrame.readiness.linuxCncKinematicsReady, true);
@@ -86,27 +135,159 @@ assert.equal(kinematicsState.lastKinematicsResult.moduleId, "xyzac-trt");
assert.equal(kinematicsState.dro.tcpX, kinematicsState.rtcpFrame.tcpPose.x); assert.equal(kinematicsState.dro.tcpX, kinematicsState.rtcpFrame.tcpPose.x);
kinematicsStore.dispatch({ type: "TOGGLE_POWER" }); kinematicsStore.dispatch({ type: "TOGGLE_POWER" });
kinematicsStore.dispatch({ type: "HOME" });
kinematicsStore.dispatch({ type: "SET_MODE", mode: "auto" });
kinematicsStore.dispatch({ type: "STEP" }); kinematicsStore.dispatch({ type: "STEP" });
kinematicsState = kinematicsStore.getState(); kinematicsState = kinematicsStore.getState();
assert.equal(kinematicsState.runState, "stepping"); assert.equal(kinematicsState.runState, "stepping");
assert.equal(kinematicsState.rtcpFrame.sourceMode, "source-derived-kinematics-wasm"); assert.equal(kinematicsState.rtcpFrame.sourceMode, "source-derived-kinematics-wasm");
assert.equal(kinematicsState.rtcpFrame.readiness.fullLinuxCncProgramExecutionReady, false); assert.equal(kinematicsState.rtcpFrame.readiness.fullLinuxCncProgramExecutionReady, false);
kinematicsStore.dispatch({ type: "ATTACH_INTERPRETER_RUNTIME", runtime: interpreterRuntime });
kinematicsStore.dispatch({
type: "LOAD_PROGRAM",
filename: "linuxcnc-canonical-demo.ngc",
content: [
"G90 G17",
"G0 X0 Y0 Z0",
"G1 X10 Y2 F100",
"G1 X12 Y4 A5 C7",
"M2",
].join("\n"),
});
kinematicsState = await waitForInterpreterExecution(kinematicsStore);
assert.equal(kinematicsState.linuxCncBoundaryAdapter.linuxCncInterpreterReady, true);
assert.equal(kinematicsState.linuxCncBoundaryReadiness.linuxCncInterpreterReady, true);
assert.equal(kinematicsState.programExecutionSourceMode, "linuxcnc-interpreter-wasm");
assert.equal(kinematicsState.programExecution.summary.motionEventCount >= 3, true);
assert.equal(kinematicsState.programExecutionTiming.motionCount >= 3, true);
assert.equal(kinematicsState.programExecutionTiming.sampleCount >= 3, true);
assert.equal(kinematicsState.programExecutionTiming.totalSeconds > 0, true);
assert.equal(kinematicsState.programExecutionTiming.semanticBoundary, "linuxcnc_tp_queue_runtime_timing_from_canonical_motion");
assert.equal(kinematicsState.programExecution.summary.plannerRuntimeReady, true);
assert.equal(kinematicsState.fullExecutionBoundary.plannerRuntimeReady, true);
assert.equal(kinematicsState.programExecution.summary.fullLinuxCncProgramExecutionReady, false);
assert.equal(kinematicsState.fullExecutionBoundary.readyForUiSimulation, true);
assert.equal(kinematicsState.fullExecutionBoundary.machineFileBackedRemapReady, false);
assert.equal(kinematicsState.fullExecutionBoundary.fullLinuxCncProgramExecutionReady, false);
assert.equal(
kinematicsState.fullExecutionBoundary.semanticBoundary,
"linuxcnc_interpreter_canonical_ready_planner_task_hal_blocked",
);
kinematicsStore.dispatch({ type: "STEP" });
kinematicsState = kinematicsStore.getState();
assert.equal(kinematicsState.programExecutionMotionIndex, 1);
assert.equal(kinematicsState.activeLine, kinematicsState.programExecution.motion[1].line);
assert.equal(kinematicsState.programRuntimeFeedback.sourceMode, "linuxcnc-tp-runtime-sample");
assert.equal(kinematicsState.programRuntimeFeedback.semanticBoundary, "linuxcnc_tp_run_cycle_feedback_without_hardware");
assert.equal(kinematicsState.axisPose.x, kinematicsState.programRuntimeFeedback.axisPose.x);
assert.notEqual(kinematicsState.axisPose.x, kinematicsState.programExecution.motion[1].axes.x);
assert.equal(kinematicsState.programRuntimeFeedback.queueDepth >= 0, true);
assert.equal(kinematicsState.programElapsedSeconds > 0, true);
assert.equal(kinematicsState.programRemainingSeconds >= 0, true);
assert.equal(kinematicsState.feed.currentVelocity > 0, true);
kinematicsStore.dispatch({
type: "LOAD_PROGRAM",
filename: "linuxcnc-switchkins-rtcp-demo.ngc",
content: [
"G90 G17",
"M428",
"G0 X0 Y0 Z0 A0 C0",
"G1 X10 Y2 Z-1 A15 C30 F120",
"M429",
"G1 X0 Y0 Z0 A0 C0 F120",
"M2",
].join("\n"),
});
kinematicsState = await waitForInterpreterExecution(kinematicsStore);
assert.equal(kinematicsState.programExecutionSourceMode, "linuxcnc-interpreter-wasm");
assert.equal(kinematicsState.programExecution.summary.switchkinsEventCount, 2);
assert.equal(kinematicsState.programExecution.motion[0].switchkinsType, 1);
assert.equal(kinematicsState.kinsType, "tcp-xyzac");
assert.equal(kinematicsState.rtcpState, "on");
assert.equal(kinematicsState.rtcpFrame.kinematicsSwitchkinsType, 1);
kinematicsStore.dispatch({ type: "STEP" });
kinematicsState = kinematicsStore.getState();
assert.equal(kinematicsState.programExecutionMotionIndex, 1);
assert.equal(kinematicsState.programRuntimeFeedback.semanticBoundary, "linuxcnc_tp_run_cycle_feedback_without_hardware");
assert.equal(kinematicsState.axisPose.a > 0 && kinematicsState.axisPose.a < 15, true);
assert.equal(kinematicsState.axisPose.c > 0 && kinematicsState.axisPose.c < 30, true);
assert.equal(kinematicsState.programRuntimeFeedback.distanceToGo > 0, true);
assert.equal(kinematicsState.kinsType, "tcp-xyzac");
assert.equal(kinematicsState.rtcpState, "on");
assert.equal(kinematicsState.rtcpFrame.kinematicsSwitchkinsType, 1);
const switchkinsStepSampleIndex = kinematicsState.programExecutionSampleIndex;
kinematicsStore.dispatch({ type: "RESUME" });
kinematicsStore.dispatch({ type: "RUN" });
kinematicsState = kinematicsStore.getState();
assert.equal(kinematicsState.programExecutionSampleIndex > switchkinsStepSampleIndex, true);
assert.equal(kinematicsState.programExecutionMotionIndex, 1);
assert.equal(kinematicsState.programRuntimeFeedback.semanticBoundary, "linuxcnc_tp_run_cycle_feedback_without_hardware");
assert.equal(kinematicsState.programRuntimeFeedback.distanceToGo > 0, true);
assert.equal(kinematicsState.kinsType, "tcp-xyzac");
assert.equal(kinematicsState.rtcpState, "on");
assert.equal(kinematicsState.rtcpFrame.kinematicsSwitchkinsType, 1);
kinematicsStore.dispatch({ type: "PAUSE" });
kinematicsState = kinematicsStore.getState();
assert.equal(kinematicsState.runState, "paused");
assert.equal(kinematicsState.machine.interpState, "paused");
assert.equal(kinematicsState.machine.taskPaused, true);
kinematicsStore.dispatch({ type: "RUN" });
kinematicsState = kinematicsStore.getState();
assert.equal(kinematicsState.operatorMessage, "run blocked: resume paused program first");
assert.equal(kinematicsState.runState, "paused");
kinematicsStore.dispatch({ type: "RESUME" });
kinematicsState = kinematicsStore.getState();
assert.equal(kinematicsState.runState, "running");
assert.equal(kinematicsState.machine.interpState, "reading");
assert.equal(kinematicsState.machine.taskPaused, false);
kinematicsStore.dispatch({ type: "STOP" });
kinematicsState = kinematicsStore.getState();
assert.equal(kinematicsState.runState, "stopped");
assert.equal(kinematicsState.machine.interpState, "idle");
kinematicsStore.dispatch({ type: "SET_FRAME_SOURCE", sourceMode: "fixture-ui-only" }); kinematicsStore.dispatch({ type: "SET_FRAME_SOURCE", sourceMode: "fixture-ui-only" });
kinematicsState = kinematicsStore.getState(); kinematicsState = kinematicsStore.getState();
assert.equal(kinematicsState.rtcpFrame.sourceMode, "fixture-ui-only"); assert.equal(kinematicsState.rtcpFrame.sourceMode, "fixture-ui-only");
assert.equal(kinematicsState.rtcpFrame.readiness.linuxCncKinematicsReady, false); assert.equal(kinematicsState.rtcpFrame.readiness.linuxCncKinematicsReady, false);
const store = createSimulationStore(); const store = createSimulationStore();
let state;
assert.equal(store.getState().availableProfiles.length, 2);
store.dispatch({ type: "SET_PROFILE", profileId: "xyzbc-trt" });
assert.equal(store.getState().machineProfile, "xyzbc-trt");
assert.equal(store.getState().profile.traj.coordinates, "XYZBC");
const xyzbcRuntime = await createLinuxCncKinematicsRuntime({ moduleId: "xyzbc-trt" });
store.dispatch({ type: "ATTACH_KINEMATICS_RUNTIME", runtime: xyzbcRuntime });
store.dispatch({ type: "SET_RTCP", enabled: true });
state = store.getState();
assert.equal(state.kinsType, "tcp-xyzbc");
assert.equal(state.rtcpFrame.kinematicsModuleId, "xyzbc-trt");
assert.equal(state.rtcpFrame.jointPose[3].axis, "B");
store.dispatch({ type: "SET_PROFILE", profileId: "xyzac-trt" });
store.dispatch({ type: "RUN" }); store.dispatch({ type: "RUN" });
assert.equal(store.getState().activeLine, 501); assert.equal(store.getState().activeLine, 501);
assert.equal(store.getState().operatorMessage, "run blocked: power or estop state"); assert.equal(store.getState().operatorMessage, "run blocked: machine must be on");
store.dispatch({ type: "TOGGLE_POWER" }); store.dispatch({ type: "TOGGLE_POWER" });
assert.equal(store.getState().machine.powerOn, true); assert.equal(store.getState().machine.powerOn, true);
store.dispatch({ type: "HOME" });
assert.equal(store.getState().machine.allHomed, true);
store.dispatch({ type: "SET_MODE", mode: "auto" });
assert.equal(store.getState().linuxCncTaskPolicy.canRunAuto, true);
assert.equal(store.getState().linuxCncTaskPolicy.canExecuteMdi, false);
store.dispatch({ type: "SET_RTCP", enabled: true }); store.dispatch({ type: "SET_RTCP", enabled: true });
let state = store.getState(); state = store.getState();
assert.equal(state.rtcpState, "on"); assert.equal(state.rtcpState, "on");
assert.equal(state.kinsType, "tcp-xyzac"); assert.equal(state.kinsType, "tcp-xyzac");
assert.equal(state.rtcpFrame.readiness.linuxCncKinematicsReady, false); assert.equal(state.rtcpFrame.readiness.linuxCncKinematicsReady, false);
@@ -120,24 +301,55 @@ assert.equal(state.activeLine, previousLine + 1);
assert.equal(state.runState, "stepping"); assert.equal(state.runState, "stepping");
assert.notEqual(state.tcpPose.x, previousTcpX); assert.notEqual(state.tcpPose.x, previousTcpX);
store.dispatch({ type: "STOP" });
store.dispatch({ type: "SET_MODE", mode: "manual" });
store.dispatch({ type: "JOG", axis: "x", direction: 1, increment: 2 }); store.dispatch({ type: "JOG", axis: "x", direction: 1, increment: 2 });
state = store.getState(); state = store.getState();
assert.equal(state.machine.mode, "jog"); assert.equal(state.machine.mode, "manual");
assert.equal(state.runState, "jogging"); assert.equal(state.runState, "jogging");
assert.equal(Math.round(state.axisPose.x), Math.round(state.rtcpFrame.axisPose.x)); assert.equal(Math.round(state.axisPose.x), Math.round(state.rtcpFrame.axisPose.x));
store.dispatch({ type: "SET_MODE", mode: "mdi" });
store.dispatch({ type: "RUN_MDI", command: "G0 X1" }); store.dispatch({ type: "RUN_MDI", command: "G0 X1" });
state = store.getState(); state = store.getState();
assert.equal(state.machine.mode, "mdi"); assert.equal(state.machine.mode, "mdi");
assert.equal(state.machine.mdiCommand, "G0 X1"); assert.equal(state.machine.mdiCommand, "G0 X1");
assert.equal(state.runState, "mdi"); assert.equal(state.runState, "mdi");
assert.equal(state.axisPose.x, 1);
assert.equal(state.programSource, "operator-mdi");
assert.equal(state.programLines[0], "G0 X1");
assert.equal(state.mdiHistory[0], "G0 X1");
const mdiRelativeBase = store.getState().axisPose;
store.dispatch({ type: "RUN_MDI", command: "G91 X2 Y-3 F1200 M3 S2400 M8" });
state = store.getState();
assert.equal(state.machine.mdiDistanceMode, "relative");
assert.equal(state.axisPose.x, mdiRelativeBase.x + 2);
assert.equal(state.axisPose.y, mdiRelativeBase.y - 3);
assert.equal(state.feed.feedRate, 1200);
assert.equal(state.spindle.enabled, true);
assert.equal(state.spindle.rpm, 2400);
assert.equal(state.coolant.flood, true);
store.dispatch({ type: "RUN_MDI", command: "M428" });
state = store.getState();
assert.equal(state.kinsType, "tcp-xyzac");
assert.equal(state.rtcpState, "on");
store.dispatch({ type: "RUN_MDI", command: "M429 M9 M5" });
state = store.getState();
assert.equal(state.kinsType, "identity");
assert.equal(state.rtcpState, "off");
assert.equal(state.coolant.flood, false);
assert.equal(state.coolant.mist, false);
assert.equal(state.spindle.enabled, false);
store.dispatch({ store.dispatch({
type: "LOAD_PROGRAM", type: "LOAD_PROGRAM",
filename: "operator-demo.ngc", filename: "operator-demo.ngc",
content: "G0 X0 Y0\nG1 X10 F100\nM30\n", content: "G0 X0 Y0\nG1 X10 F100\nM30\n",
}); });
state = store.getState(); state = await waitForInterpreterExecution(store);
assert.equal(state.activeProgram, "operator-demo.ngc"); assert.equal(state.activeProgram, "operator-demo.ngc");
assert.equal(state.programSource, "operator-file"); assert.equal(state.programSource, "operator-file");
assert.equal(state.programStartLine, 1); assert.equal(state.programStartLine, 1);
@@ -169,13 +381,15 @@ store.dispatch({ type: "ADJUST_SPINDLE_OVERRIDE", delta: 10 });
state = store.getState(); state = store.getState();
assert.equal(state.spindle.override, 110); assert.equal(state.spindle.override, 110);
const floodBeforeToggle = store.getState().coolant.flood;
store.dispatch({ type: "TOGGLE_COOLANT", kind: "flood" }); store.dispatch({ type: "TOGGLE_COOLANT", kind: "flood" });
state = store.getState(); state = store.getState();
assert.equal(state.coolant.flood, false); assert.equal(state.coolant.flood, !floodBeforeToggle);
const mistBeforeToggle = store.getState().coolant.mist;
store.dispatch({ type: "TOGGLE_COOLANT", kind: "mist" }); store.dispatch({ type: "TOGGLE_COOLANT", kind: "mist" });
state = store.getState(); state = store.getState();
assert.equal(state.coolant.mist, true); assert.equal(state.coolant.mist, !mistBeforeToggle);
store.dispatch({ type: "SET_VIEW", view: "x" }); store.dispatch({ type: "SET_VIEW", view: "x" });
state = store.getState(); state = store.getState();
@@ -195,6 +409,7 @@ store.dispatch({ type: "TOGGLE_FULLSCREEN" });
state = store.getState(); state = store.getState();
assert.equal(state.preview.fullscreen, true); assert.equal(state.preview.fullscreen, true);
store.dispatch({ type: "SET_MODE", mode: "manual" });
store.dispatch({ type: "HOME" }); store.dispatch({ type: "HOME" });
state = store.getState(); state = store.getState();
assert.equal(state.axisPose.x, 43); assert.equal(state.axisPose.x, 43);
@@ -209,6 +424,8 @@ assert.equal(state.runState, "estopped");
store.dispatch({ type: "RESET" }); store.dispatch({ type: "RESET" });
state = store.getState(); state = store.getState();
assert.equal(state.machine.estopActive, false); assert.equal(state.machine.estopActive, false);
assert.equal(state.operatorMessage, "machine reset complete"); assert.equal(state.machine.powerOn, false);
assert.equal(state.machine.taskState, "estop-reset");
assert.equal(state.operatorMessage, "estop reset; machine off");
console.log("rtcp_store_smoke=ok"); console.log("rtcp_store_smoke=ok");