diff --git a/wasm-port/runtime/core/linuxcnc_wrap/linuxcnc_tp_wasm.c b/wasm-port/runtime/core/linuxcnc_wrap/linuxcnc_tp_wasm.c index 0aab14d..7e66dc0 100644 --- a/wasm-port/runtime/core/linuxcnc_wrap/linuxcnc_tp_wasm.c +++ b/wasm-port/runtime/core/linuxcnc_wrap/linuxcnc_tp_wasm.c @@ -1,4 +1,5 @@ #include +#include #include #include #include @@ -91,6 +92,76 @@ static int pose_near(const EmcPose *actual, const EmcPose *expected) 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) { 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(¢er, 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) { init_motion_state(); diff --git a/wasm-port/runtime/sdk/src/linuxcnc-tp.js b/wasm-port/runtime/sdk/src/linuxcnc-tp.js new file mode 100644 index 0000000..6761b09 --- /dev/null +++ b/wasm-port/runtime/sdk/src/linuxcnc-tp.js @@ -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; +} diff --git a/wasm-port/tests/wasm/node/verify_tp_wasm.mjs b/wasm-port/tests/wasm/node/verify_tp_wasm.mjs index 2d4af25..222256f 100644 --- a/wasm-port/tests/wasm/node/verify_tp_wasm.mjs +++ b/wasm-port/tests/wasm/node/verify_tp_wasm.mjs @@ -80,4 +80,84 @@ try { 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"); diff --git a/wasm-port/tools/build_tp_wasm.sh b/wasm-port/tools/build_tp_wasm.sh index f25275e..5ce40a5 100755 --- a/wasm-port/tools/build_tp_wasm.sh +++ b/wasm-port/tools/build_tp_wasm.sh @@ -104,5 +104,5 @@ link_wasm_module \ -s ENVIRONMENT=web,node \ -s ALLOW_MEMORY_GROWTH=1 \ -s NO_EXIT_RUNTIME=1 \ - -s EXPORTED_FUNCTIONS='["_malloc","_free","_lctp_run_probe","_lctp_free_string"]' \ - -s EXPORTED_RUNTIME_METHODS='["UTF8ToString"]' + -s EXPORTED_FUNCTIONS='["_malloc","_free","_lctp_run_probe","_lctp_run_canonical_motion_timing","_lctp_free_string"]' \ + -s EXPORTED_RUNTIME_METHODS='["UTF8ToString","stringToUTF8","lengthBytesUTF8"]' diff --git a/web-rtcp-5axis-sim-plan/app/package.json b/web-rtcp-5axis-sim-plan/app/package.json index 4152ce2..02d6318 100644 --- a/web-rtcp-5axis-sim-plan/app/package.json +++ b/web-rtcp-5axis-sim-plan/app/package.json @@ -6,8 +6,8 @@ "scripts": { "build": "node scripts/build-static.mjs", "dev": "python3 -m http.server 4173", - "smoke": "bash ../tests/browser/verify_gmoccapy_shell_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": "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_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": {}, "devDependencies": {} diff --git a/web-rtcp-5axis-sim-plan/app/scripts/build-static.mjs b/web-rtcp-5axis-sim-plan/app/scripts/build-static.mjs index bf82767..96af09d 100644 --- a/web-rtcp-5axis-sim-plan/app/scripts/build-static.mjs +++ b/web-rtcp-5axis-sim-plan/app/scripts/build-static.mjs @@ -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 { fileURLToPath } from "node:url"; const appRoot = dirname(dirname(fileURLToPath(import.meta.url))); +const repoRoot = dirname(dirname(appRoot)); const distDir = join(appRoot, "dist"); await rm(distDir, { recursive: true, force: true }); await mkdir(distDir, { recursive: true }); await cp(join(appRoot, "index.html"), join(distDir, "index.html")); 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 forbiddenDependencies = ["react", "vue", "@angular/core", "svelte"]; @@ -22,4 +28,59 @@ if (forbidden.length > 0) { 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"); + +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)); + } +} diff --git a/web-rtcp-5axis-sim-plan/app/src/main.js b/web-rtcp-5axis-sim-plan/app/src/main.js index 21ce8f6..08d775c 100644 --- a/web-rtcp-5axis-sim-plan/app/src/main.js +++ b/web-rtcp-5axis-sim-plan/app/src/main.js @@ -1,5 +1,10 @@ import { createSimulationStore } from "./state/store.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"); @@ -9,11 +14,134 @@ if (!app) { const store = createSimulationStore(); 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 = { getState: store.getState, dispatch: store.dispatch, + refreshKinematicsFrame: store.refreshKinematicsFrame, + saveSession: store.saveSession, + restoreSession: store.restoreSession, + stageMachineFiles: store.stageMachineFiles, + runFullBoundaryAudit: store.runFullBoundaryAudit, getRegions: shell.getRegions, + iniConfigReady, + kinematicsRuntimeReady, + interpreterRuntimeReady, }; 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(" | "), + }; + } +} diff --git a/web-rtcp-5axis-sim-plan/app/src/profiles/index.js b/web-rtcp-5axis-sim-plan/app/src/profiles/index.js new file mode 100644 index 0000000..2e98e18 --- /dev/null +++ b/web-rtcp-5axis-sim-plan/app/src/profiles/index.js @@ -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; +} diff --git a/web-rtcp-5axis-sim-plan/app/src/profiles/source-reference-map.js b/web-rtcp-5axis-sim-plan/app/src/profiles/source-reference-map.js index ff63141..bfceda3 100644 --- a/web-rtcp-5axis-sim-plan/app/src/profiles/source-reference-map.js +++ b/web-rtcp-5axis-sim-plan/app/src/profiles/source-reference-map.js @@ -71,6 +71,62 @@ export const linuxCncSourceReferenceMap = [ boundary: "linuxcnc_program_reference", 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) { diff --git a/web-rtcp-5axis-sim-plan/app/src/profiles/xyzbc-trt.js b/web-rtcp-5axis-sim-plan/app/src/profiles/xyzbc-trt.js new file mode 100644 index 0000000..f247d32 --- /dev/null +++ b/web-rtcp-5axis-sim-plan/app/src/profiles/xyzbc-trt.js @@ -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", + ], +}; diff --git a/web-rtcp-5axis-sim-plan/app/src/runtime/execution-timing.js b/web-rtcp-5axis-sim-plan/app/src/runtime/execution-timing.js new file mode 100644 index 0000000..e5591c0 --- /dev/null +++ b/web-rtcp-5axis-sim-plan/app/src/runtime/execution-timing.js @@ -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; +} diff --git a/web-rtcp-5axis-sim-plan/app/src/runtime/five-axis-session.js b/web-rtcp-5axis-sim-plan/app/src/runtime/five-axis-session.js new file mode 100644 index 0000000..5dfe275 --- /dev/null +++ b/web-rtcp-5axis-sim-plan/app/src/runtime/five-axis-session.js @@ -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.`); + } +} diff --git a/web-rtcp-5axis-sim-plan/app/src/runtime/full-execution-boundary.js b/web-rtcp-5axis-sim-plan/app/src/runtime/full-execution-boundary.js new file mode 100644 index 0000000..d8bf606 --- /dev/null +++ b/web-rtcp-5axis-sim-plan/app/src/runtime/full-execution-boundary.js @@ -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, + }, + }; +} diff --git a/web-rtcp-5axis-sim-plan/app/src/runtime/linuxcnc-boundary-adapter.js b/web-rtcp-5axis-sim-plan/app/src/runtime/linuxcnc-boundary-adapter.js index 0293151..aa95a28 100644 --- a/web-rtcp-5axis-sim-plan/app/src/runtime/linuxcnc-boundary-adapter.js +++ b/web-rtcp-5axis-sim-plan/app/src/runtime/linuxcnc-boundary-adapter.js @@ -4,7 +4,7 @@ import { createProfileSourceReferenceSummary } from "../profiles/source-referenc export function createLinuxCncBoundaryAdapter({ profile = xyzacTrtProfile, - panelSchema = xyzacTrtPyvcpPanelSchema, + panelSchema = profile.panelSchema || xyzacTrtPyvcpPanelSchema, runtime = null, } = {}) { const sourceSummary = createProfileSourceReferenceSummary(profile.id); @@ -14,6 +14,8 @@ export function createLinuxCncBoundaryAdapter({ const runtimeReady = kinematicsRuntimeReady && interpreterRuntimeReady; const linuxCncKinematicsReady = kinematicsRuntimeReady && runtime.kinematicsWasm.sourceMode === "source-derived-kinematics-wasm"; + const linuxCncInterpreterReady = interpreterRuntimeReady + && runtime.interpreterWasm.sourceMode === "linuxcnc-interpreter-wasm"; return { apiName: "web-rtcp-5axis-linuxcnc-boundary-adapter", @@ -24,13 +26,14 @@ export function createLinuxCncBoundaryAdapter({ runtimeReady, kinematicsRuntimeReady, interpreterRuntimeReady, + linuxCncInterpreterReady, profileSummary: createProfileSummary(profile), linuxCncKinematicsReady, promotionAllowed: linuxCncKinematicsReady, fullLinuxCncProgramExecutionReady: false, semanticBoundary: linuxCncKinematicsReady - ? interpreterRuntimeReady - ? "linuxcnc_runtime_supplied_but_interpreter_or_remap_not_promoted" + ? linuxCncInterpreterReady + ? "linuxcnc_kinematics_and_interpreter_wasm_connected_remap_planner_not_promoted" : "linuxcnc_kinematics_wasm_runtime_connected" : "adapter_entrypoint_only_runtime_not_connected", adapterPoints: { @@ -74,6 +77,7 @@ export function createLinuxCncBoundaryReadiness(adapter = createLinuxCncBoundary && !missing.includes("PyVCP/HAL panel schema"), missing, linuxCncKinematicsReady: adapter.linuxCncKinematicsReady, + linuxCncInterpreterReady: adapter.linuxCncInterpreterReady, promotionAllowed: adapter.promotionAllowed, fullLinuxCncProgramExecutionReady: adapter.fullLinuxCncProgramExecutionReady, semanticBoundary: adapter.semanticBoundary, diff --git a/web-rtcp-5axis-sim-plan/app/src/runtime/linuxcnc-ini-runtime.js b/web-rtcp-5axis-sim-plan/app/src/runtime/linuxcnc-ini-runtime.js new file mode 100644 index 0000000..3dcc422 --- /dev/null +++ b/web-rtcp-5axis-sim-plan/app/src/runtime/linuxcnc-ini-runtime.js @@ -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, + })); +} diff --git a/web-rtcp-5axis-sim-plan/app/src/runtime/linuxcnc-interpreter-runtime.js b/web-rtcp-5axis-sim-plan/app/src/runtime/linuxcnc-interpreter-runtime.js new file mode 100644 index 0000000..9812862 --- /dev/null +++ b/web-rtcp-5axis-sim-plan/app/src/runtime/linuxcnc-interpreter-runtime.js @@ -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"; +} diff --git a/web-rtcp-5axis-sim-plan/app/src/runtime/linuxcnc-interpreter-worker-client.js b/web-rtcp-5axis-sim-plan/app/src/runtime/linuxcnc-interpreter-worker-client.js new file mode 100644 index 0000000..323e02c --- /dev/null +++ b/web-rtcp-5axis-sim-plan/app/src/runtime/linuxcnc-interpreter-worker-client.js @@ -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 }); + }); + }; +} diff --git a/web-rtcp-5axis-sim-plan/app/src/runtime/linuxcnc-interpreter-worker.js b/web-rtcp-5axis-sim-plan/app/src/runtime/linuxcnc-interpreter-worker.js new file mode 100644 index 0000000..2b012ed --- /dev/null +++ b/web-rtcp-5axis-sim-plan/app/src/runtime/linuxcnc-interpreter-worker.js @@ -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 }); +} diff --git a/web-rtcp-5axis-sim-plan/app/src/runtime/linuxcnc-kinematics-runtime.js b/web-rtcp-5axis-sim-plan/app/src/runtime/linuxcnc-kinematics-runtime.js index 41c08bb..5fd70f1 100644 --- a/web-rtcp-5axis-sim-plan/app/src/runtime/linuxcnc-kinematics-runtime.js +++ b/web-rtcp-5axis-sim-plan/app/src/runtime/linuxcnc-kinematics-runtime.js @@ -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_JOINT_COUNT = 5; const SOURCE_MODE = "source-derived-kinematics-wasm"; const SEMANTIC_BOUNDARY = "linuxcnc_kinematics_wasm_c_abi"; -const __dirname = dirname(fileURLToPath(import.meta.url)); -const defaultWasmRoot = resolve(__dirname, "../../../../wasm-port/build/wasm/kinematics"); +const DEFAULT_SDK_MODULE_URL = "../../../../wasm-port/runtime/sdk/src/linuxcnc-kinematics.js"; export async function createLinuxCncKinematicsRuntime({ moduleId = DEFAULT_MODULE_ID, moduleOptions = null, switchkinsType = 0, jointCount = DEFAULT_JOINT_COUNT, - wasmRoot = defaultWasmRoot, + wasmRoot = null, + sdkModuleUrl = DEFAULT_SDK_MODULE_URL, } = {}) { + const { + createLinuxCncKinematicsSdk, + linuxCncKinematicsWasmFile, + supportedLinuxCncKinematicsModules, + } = await import(sdkModuleUrl); const wasmFile = linuxCncKinematicsWasmFile(moduleId); if (!wasmFile) { throw new Error(`unsupported LinuxCNC kinematics module: ${moduleId}`); } - const resolvedModuleOptions = moduleOptions || { - wasmBinary: readFileSync(resolve(wasmRoot, wasmFile)), - print() {}, - printErr() {}, - }; + const resolvedModuleOptions = moduleOptions || await createDefaultModuleOptions({ wasmRoot, wasmFile }); 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) : 0; @@ -45,8 +37,13 @@ export async function createLinuxCncKinematicsRuntime({ loaded: true, sourceMode: SOURCE_MODE, semanticBoundary: SEMANTIC_BOUNDARY, - switchkinsType, - switchRc, + executionContext: "direct", + get switchkinsType() { + return activeSwitchkinsType; + }, + get switchRc() { + return activeSwitchRc; + }, jointCount, sdk, @@ -59,11 +56,21 @@ export async function createLinuxCncKinematicsRuntime({ loaded: true, sourceMode: SOURCE_MODE, semanticBoundary: SEMANTIC_BOUNDARY, - switchkinsType, - switchRc, + executionContext: "direct", + 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 = {}) { return sdk.forward(joints, options); }, @@ -82,7 +89,7 @@ export async function createLinuxCncKinematicsRuntime({ ); return { moduleId, - switchkinsType, + switchkinsType: activeSwitchkinsType, forward, 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) { if (!runtime?.loaded) return null; return { @@ -100,6 +136,8 @@ export function createLinuxCncKinematicsRuntimeDescriptor(runtime) { loaded: runtime.loaded, sourceMode: runtime.sourceMode, semanticBoundary: runtime.semanticBoundary, + executionContext: runtime.executionContext || "direct", + workerUrl: runtime.workerUrl || null, switchkinsType: runtime.switchkinsType, switchRc: runtime.switchRc, }; diff --git a/web-rtcp-5axis-sim-plan/app/src/runtime/linuxcnc-kinematics-worker-client.js b/web-rtcp-5axis-sim-plan/app/src/runtime/linuxcnc-kinematics-worker-client.js new file mode 100644 index 0000000..9f0bca4 --- /dev/null +++ b/web-rtcp-5axis-sim-plan/app/src/runtime/linuxcnc-kinematics-worker-client.js @@ -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 }); + }); + }; +} diff --git a/web-rtcp-5axis-sim-plan/app/src/runtime/linuxcnc-kinematics-worker.js b/web-rtcp-5axis-sim-plan/app/src/runtime/linuxcnc-kinematics-worker.js new file mode 100644 index 0000000..9dd3b93 --- /dev/null +++ b/web-rtcp-5axis-sim-plan/app/src/runtime/linuxcnc-kinematics-worker.js @@ -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 }); +} diff --git a/web-rtcp-5axis-sim-plan/app/src/runtime/linuxcnc-machine-file-staging.js b/web-rtcp-5axis-sim-plan/app/src/runtime/linuxcnc-machine-file-staging.js new file mode 100644 index 0000000..d56dd3e --- /dev/null +++ b/web-rtcp-5axis-sim-plan/app/src/runtime/linuxcnc-machine-file-staging.js @@ -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"; +} diff --git a/web-rtcp-5axis-sim-plan/app/src/runtime/rtcp-frame.js b/web-rtcp-5axis-sim-plan/app/src/runtime/rtcp-frame.js index 835fb4e..83c1189 100644 --- a/web-rtcp-5axis-sim-plan/app/src/runtime/rtcp-frame.js +++ b/web-rtcp-5axis-sim-plan/app/src/runtime/rtcp-frame.js @@ -25,7 +25,7 @@ export function buildRtcpFrame({ const pose = normalizeAxisPose(axisPose); const toolLength = 84.019; - const toolAxisVector = computeToolAxisVector(pose.a, pose.c); + const toolAxisVector = computeToolAxisVector(pose, profile); const compensation = rtcpEnabled ? { x: -toolAxisVector.x * toolLength, @@ -85,7 +85,7 @@ function buildLinuxCncKinematicsFrame({ ...wasmPose, }); const jointValues = Array.isArray(inverse.joints) ? inverse.joints : []; - const toolAxisVector = computeToolAxisVector(pose.a, pose.c); + const toolAxisVector = computeToolAxisVector(pose, profile); return { apiName: "web-rtcp-5axis-motion-frame", @@ -97,7 +97,7 @@ function buildLinuxCncKinematicsFrame({ rtcpEnabled, rtcpState: rtcpEnabled ? "on" : "off", axisPose: pose, - jointPose: buildJointPoseFromLinuxCncJoints(jointValues, pose), + jointPose: buildJointPoseFromLinuxCncJoints(jointValues, pose, profile), tcpPose: { x: pose.x, y: pose.y, @@ -150,9 +150,9 @@ function buildJointPose(pose) { ]; } -function buildJointPoseFromLinuxCncJoints(joints, fallbackPose) { - const axes = ["X", "Y", "Z", "A", "C"]; - const fallbackValues = [fallbackPose.x, fallbackPose.y, fallbackPose.z, fallbackPose.a, fallbackPose.c]; +function buildJointPoseFromLinuxCncJoints(joints, fallbackPose, profile = xyzacTrtProfile) { + const axes = profile?.traj?.coordinates === "XYZBC" ? ["X", "Y", "Z", "B", "C"] : ["X", "Y", "Z", "A", "C"]; + const fallbackValues = axes.map((axis) => fallbackPose[axis.toLowerCase()] ?? 0); return axes.map((axis, joint) => ({ joint, axis, @@ -171,18 +171,29 @@ function normalizeLinuxCncPose(pose = {}) { }; } -function computeToolAxisVector(aDegrees, cDegrees) { - const a = aDegrees * DEG_TO_RAD; +function computeToolAxisVector(pose, profile = xyzacTrtProfile) { + 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 sinA = Math.sin(a); - const cosA = Math.cos(a); + const sinTilt = Math.sin(tilt); + const cosTilt = Math.cos(tilt); const sinC = Math.sin(c); const cosC = Math.cos(c); + if (coordinates.includes("B")) { + return normalizeVector({ + x: sinTilt * cosC, + y: sinTilt * sinC, + z: cosTilt, + }); + } + return normalizeVector({ - x: sinA * sinC, - y: -sinA * cosC, - z: cosA, + x: sinTilt * sinC, + y: -sinTilt * cosC, + z: cosTilt, }); } diff --git a/web-rtcp-5axis-sim-plan/app/src/state/linuxcnc-task-policy.js b/web-rtcp-5axis-sim-plan/app/src/state/linuxcnc-task-policy.js new file mode 100644 index 0000000..617020d --- /dev/null +++ b/web-rtcp-5axis-sim-plan/app/src/state/linuxcnc-task-policy.js @@ -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, + }; +} diff --git a/web-rtcp-5axis-sim-plan/app/src/state/store.js b/web-rtcp-5axis-sim-plan/app/src/state/store.js index 6558a70..04e38f5 100644 --- a/web-rtcp-5axis-sim-plan/app/src/state/store.js +++ b/web-rtcp-5axis-sim-plan/app/src/state/store.js @@ -1,9 +1,32 @@ import { buildRtcpFrame } from "../runtime/rtcp-frame.js"; import { createLinuxCncBoundaryAdapter, createLinuxCncBoundaryReadiness } from "../runtime/linuxcnc-boundary-adapter.js"; -import { xyzacTrtProfile } from "../profiles/xyzac-trt.js"; +import { createFullLinuxCncExecutionBoundary } from "../runtime/full-execution-boundary.js"; +import { + DEFAULT_SESSION_FILENAME, + DEFAULT_SESSION_ID, + createFiveAxisSessionPayload, + loadFiveAxisSessionSnapshot, + restoreFiveAxisSessionState, + saveFiveAxisSessionSnapshot, +} from "../runtime/five-axis-session.js"; +import { fiveAxisProfiles, getFiveAxisProfile } from "../profiles/index.js"; +import { applyIniConfigToProfile } from "../runtime/linuxcnc-ini-runtime.js"; +import { + listLinuxCncGcodeSources, + selectMachineFileProgram, + stageProfileMachineFiles, +} from "../runtime/linuxcnc-machine-file-staging.js"; +import { + createLinuxCncTaskPolicyStatus, + gateLinuxCncTaskAction, + normalizeLinuxCncTaskMode, +} from "./linuxcnc-task-policy.js"; +import { buildProgramExecutionTiming, timingAtMotionIndex } from "../runtime/execution-timing.js"; + +const defaultProfile = getFiveAxisProfile("xyzac-trt"); const initialLinuxCncBoundaryAdapter = createLinuxCncBoundaryAdapter({ - profile: xyzacTrtProfile, + profile: defaultProfile, }); const initialLinuxCncBoundaryReadiness = createLinuxCncBoundaryReadiness(initialLinuxCncBoundaryAdapter); @@ -18,17 +41,54 @@ const initialAxisPose = { const initialState = { machineProfile: "xyzac-trt", - profile: xyzacTrtProfile, + availableProfiles: fiveAxisProfiles.map(({ id, title, traj, kinematicsModuleId, kinematics }) => ({ + id, + title, + coordinates: traj.coordinates, + kinematicsModuleId: kinematicsModuleId || id, + kinematics, + })), + profile: defaultProfile, sessionName: "gmoccapy-web-session", + sessionPersistence: { + apiName: "web-rtcp-5axis-session-persistence-state", + sessionId: DEFAULT_SESSION_ID, + filename: DEFAULT_SESSION_FILENAME, + status: "not-saved", + path: null, + savedAt: null, + restoredAt: null, + lastError: null, + }, + machineFileStaging: { + apiName: "web-rtcp-5axis-machine-file-staging-state", + status: "not-staged", + profileId: null, + fileCount: 0, + opfsRoot: null, + savedAt: null, + lastError: null, + plan: null, + save: null, + gcodeSources: [], + selectedGcodeSourceRel: null, + }, sourceMode: "fixture-ui-only", frameSourceMode: "fixture-ui-only", machine: { powerOn: false, estopActive: false, + taskState: "estop-reset", mode: "manual", + interpState: "idle", + interpResumeState: "idle", + taskPaused: false, + allHomed: false, + noForceHoming: false, jogAxis: "x", jogIncrement: 1, mdiCommand: "G0 X0 Y0 Z0", + mdiDistanceMode: "absolute", resetCount: 0, }, runState: "idle", @@ -57,9 +117,35 @@ const initialState = { rtcpFrame: null, kinematicsRuntime: null, kinematicsRuntimeReadiness: null, + kinematicsExecutionContext: "none", + interpreterRuntime: null, + interpreterRuntimeReadiness: null, + programExecution: null, + programExecutionTiming: null, + programElapsedSeconds: 0, + programRemainingSeconds: 0, + programExecutionSourceMode: "fixture-line-playback", + programExecutionMotionIndex: 0, + programExecutionSampleIndex: 0, + programRuntimeFeedback: null, + interpreterExecutionPending: false, + interpreterExecutionSequence: 0, + machineFileExecution: null, + fullExecutionBoundary: null, + linuxCncTaskPolicy: null, + asyncFrameRefreshPending: false, + asyncFrameRefreshSequence: 0, lastKinematicsResult: null, linuxCncBoundaryAdapter: initialLinuxCncBoundaryAdapter, linuxCncBoundaryReadiness: initialLinuxCncBoundaryReadiness, + linuxCncIniConfig: null, + iniConfigReadiness: { + apiName: "web-rtcp-5axis-ini-config-readiness", + loaded: false, + ready: false, + path: null, + missing: ["LinuxCNC INI not loaded"], + }, dro: { x: 43.0, y: -32.15, @@ -102,7 +188,9 @@ const initialState = { holder: "CAT40", }, operatorMessage: "ready", + mdiHistory: [], }; +initialState.linuxCncTaskPolicy = createLinuxCncTaskPolicyStatus(initialState); const programLines = [ "N4860 Y[#*-39.009]", @@ -138,9 +226,11 @@ export function createSimulationStore(seed = {}) { toolAxisVector: seedFrame.toolAxisVector, rtcpState: seedFrame.rtcpState, rtcpFrame: seedFrame, - dro: buildDroFromFrame(seedFrame), + fullExecutionBoundary: null, + dro: buildDroFromFrame(seedFrame, seed.programRuntimeFeedback || initialState.programRuntimeFeedback), programLines: seed.programLines || programLines, }; + state.fullExecutionBoundary = createFullLinuxCncExecutionBoundary(state); const listeners = new Set(); const notify = () => { @@ -150,10 +240,16 @@ export function createSimulationStore(seed = {}) { }; const setState = (patch) => { - const next = { ...state, ...patch }; + const merged = { ...state, ...patch }; + const mergedMachine = normalizeMachineForLinuxCncTask(merged.machine, merged.runState); + const next = { + ...merged, + machine: mergedMachine, + axisPose: clampAxisPoseToProfile(merged.axisPose, merged.profile), + }; const frameState = buildFrameForState(next, patch); const frame = patch.rtcpFrame || frameState.frame; - state = { + const nextState = { ...next, sourceMode: frame.sourceMode, frameSourceMode: frame.sourceMode, @@ -164,9 +260,15 @@ export function createSimulationStore(seed = {}) { rtcpState: frame.rtcpState, rtcpFrame: frame, lastKinematicsResult: frameState.lastKinematicsResult, - dro: buildDroFromFrame(frame), + dro: buildDroFromFrame(frame, next.programRuntimeFeedback), + }; + state = { + ...nextState, + linuxCncTaskPolicy: createLinuxCncTaskPolicyStatus(nextState), + fullExecutionBoundary: createFullLinuxCncExecutionBoundary(nextState), }; notify(); + scheduleAsyncKinematicsRefresh(); }; const dispatch = (action) => { @@ -195,12 +297,13 @@ export function createSimulationStore(seed = {}) { profile: state.profile, runtime: { kinematicsWasm: runtimeDescriptor, - interpreterWasm: null, + interpreterWasm: createInterpreterDescriptor(state.interpreterRuntime), }, }); setState({ kinematicsRuntime: runtime, kinematicsRuntimeReadiness: readiness, + kinematicsExecutionContext: runtime?.executionContext || (runtime?.loaded ? "direct" : "none"), linuxCncBoundaryAdapter: adapter, linuxCncBoundaryReadiness: createLinuxCncBoundaryReadiness(adapter), sourceMode: runtime?.loaded ? "source-derived-kinematics-wasm" : "fixture-ui-only", @@ -211,6 +314,420 @@ export function createSimulationStore(seed = {}) { }); } break; + case "ATTACH_INI_CONFIG": + { + const baseProfile = getFiveAxisProfile(action.profileId || state.machineProfile); + const profile = applyIniConfigToProfile(baseProfile, action.iniConfig); + const adapter = createLinuxCncBoundaryAdapter({ + profile, + runtime: { + kinematicsWasm: createKinematicsDescriptor(state.kinematicsRuntime), + interpreterWasm: createInterpreterDescriptor(state.interpreterRuntime), + }, + }); + setState({ + machineProfile: profile.id, + profile, + kinsType: normalizeKinsTypeForProfile(state.kinsType, profile), + axisPose: clampAxisPoseToProfile(state.axisPose, profile), + linuxCncIniConfig: action.iniConfig, + iniConfigReadiness: createIniConfigReadiness(action.iniConfig), + linuxCncBoundaryAdapter: adapter, + linuxCncBoundaryReadiness: createLinuxCncBoundaryReadiness(adapter), + operatorMessage: `LinuxCNC INI loaded ${action.iniConfig.path}`, + }); + } + break; + case "INI_CONFIG_FAILED": + setState({ + iniConfigReadiness: { + apiName: "web-rtcp-5axis-ini-config-readiness", + loaded: false, + ready: false, + path: action.path || state.profile.iniPath, + missing: [action.error], + }, + operatorMessage: `LinuxCNC INI error: ${action.error}`, + }); + break; + case "SET_PROFILE": + { + const profile = getFiveAxisProfile(action.profileId); + const adapter = createLinuxCncBoundaryAdapter({ + profile, + runtime: { + kinematicsWasm: null, + interpreterWasm: createInterpreterDescriptor(state.interpreterRuntime), + }, + }); + setState({ + machineProfile: profile.id, + profile, + activeProgram: profile.samplePrograms[0] || state.activeProgram, + kinsType: "identity", + rtcpState: "off", + kinematicsRuntime: null, + kinematicsRuntimeReadiness: null, + kinematicsExecutionContext: "none", + linuxCncIniConfig: null, + iniConfigReadiness: initialState.iniConfigReadiness, + linuxCncBoundaryAdapter: adapter, + linuxCncBoundaryReadiness: createLinuxCncBoundaryReadiness(adapter), + sessionPersistence: { + ...state.sessionPersistence, + status: "profile-switched", + lastError: null, + }, + operatorMessage: `profile ${profile.id}`, + }); + } + break; + case "ATTACH_INTERPRETER_RUNTIME": + { + const runtime = action.runtime || null; + const readiness = runtime?.readiness ? runtime.readiness() : null; + const adapter = createLinuxCncBoundaryAdapter({ + profile: state.profile, + runtime: { + kinematicsWasm: createKinematicsDescriptor(state.kinematicsRuntime), + interpreterWasm: createInterpreterDescriptor(runtime), + }, + }); + setState({ + interpreterRuntime: runtime, + interpreterRuntimeReadiness: readiness, + linuxCncBoundaryAdapter: adapter, + linuxCncBoundaryReadiness: createLinuxCncBoundaryReadiness(adapter), + operatorMessage: runtime?.loaded + ? "LinuxCNC interpreter ready" + : "LinuxCNC interpreter runtime missing", + }); + } + break; + case "RUN_INTERPRETER_PROGRAM": + if (!state.interpreterRuntime?.loaded) { + setState({ + programExecutionSourceMode: "fixture-line-playback", + operatorMessage: "LinuxCNC interpreter unavailable; using fixture line playback", + }); + break; + } + { + const programText = state.programLines.join("\n"); + const sequence = state.interpreterExecutionSequence + 1; + setState({ + interpreterExecutionPending: true, + interpreterExecutionSequence: sequence, + operatorMessage: "LinuxCNC interpreter running program", + }); + try { + Promise.resolve(state.interpreterRuntime.runProgram(programText)) + .then((execution) => { + dispatch({ + type: "INTERPRETER_PROGRAM_COMPLETE", + sequence, + execution, + }); + }) + .catch((error) => { + dispatch({ + type: "INTERPRETER_PROGRAM_FAILED", + sequence, + error: error instanceof Error ? error.message : String(error), + }); + }); + } catch (error) { + dispatch({ + type: "INTERPRETER_PROGRAM_FAILED", + sequence, + error: error instanceof Error ? error.message : String(error), + }); + } + } + break; + case "INTERPRETER_PROGRAM_COMPLETE": + if (action.sequence !== state.interpreterExecutionSequence) { + break; + } + { + const execution = action.execution; + const timing = buildTimingForState(state, execution); + const firstTiming = timingAtMotionIndex(timing, 0); + const firstMotion = execution.motion[0] || null; + const firstKinsType = kinsTypeFromProgramMotion(state, firstMotion) || state.kinsType; + const firstFeedback = createInitialProgramRuntimeFeedback({ + state, + timing, + motion: firstMotion, + timingSnapshot: firstTiming, + }); + setState({ + programExecution: execution, + programExecutionTiming: timing, + programExecutionSourceMode: execution.sourceMode, + machineFileExecution: execution.machineFilePlan ? execution : state.machineFileExecution, + programExecutionMotionIndex: 0, + programExecutionSampleIndex: 0, + programRuntimeFeedback: firstFeedback, + programElapsedSeconds: firstTiming.elapsedSeconds, + programRemainingSeconds: firstTiming.remainingSeconds, + interpreterExecutionPending: false, + activeLine: firstMotion?.line || state.programStartLine, + axisPose: axisPoseFromCanonicalMotion(firstMotion, state.axisPose), + kinsType: firstKinsType, + rtcpState: rtcpStateFromKinsType(firstKinsType), + preview: { + ...state.preview, + pathPoints: Math.max(execution.summary.motionEventCount, 1), + }, + feed: { + ...state.feed, + currentVelocity: Number.isFinite(firstFeedback?.currentVelocityMmPerMin) + ? firstFeedback.currentVelocityMmPerMin + : state.feed.currentVelocity, + }, + operatorMessage: execution.summary.switchkinsEventCount > 0 + ? `LinuxCNC interpreter motion events ${execution.summary.motionEventCount}, switchkins ${execution.summary.switchkinsCodes.join("/")}` + : `LinuxCNC interpreter motion events ${execution.summary.motionEventCount}`, + }); + } + break; + case "INTERPRETER_PROGRAM_FAILED": + if (action.sequence !== state.interpreterExecutionSequence) { + break; + } + setState({ + programExecution: null, + programExecutionTiming: null, + programElapsedSeconds: 0, + programRemainingSeconds: 0, + programExecutionSourceMode: "fixture-line-playback", + programExecutionSampleIndex: 0, + programRuntimeFeedback: null, + interpreterExecutionPending: false, + operatorMessage: `LinuxCNC interpreter blocked: ${action.error}`, + }); + break; + case "RUN_MACHINE_FILE_PROGRAM": + if (!state.interpreterRuntime?.loaded || typeof state.interpreterRuntime.runMachineFileProgram !== "function") { + setState({ + operatorMessage: "machine-file run blocked: LinuxCNC interpreter machine-file runtime unavailable", + }); + break; + } + if (!state.machineFileStaging?.plan || !state.machineFileStaging?.save) { + setState({ + operatorMessage: "machine-file run blocked: machine files not staged", + }); + break; + } + if (!state.machineFileStaging.selectedGcodeSourceRel) { + setState({ + operatorMessage: "machine-file run blocked: select a LinuxCNC source-directory 5-axis G-code program", + }); + break; + } + { + const sequence = state.interpreterExecutionSequence + 1; + setState({ + interpreterExecutionPending: true, + interpreterExecutionSequence: sequence, + operatorMessage: "LinuxCNC machine-file remap run starting", + }); + try { + Promise.resolve(state.interpreterRuntime.runMachineFileProgram({ + plan: selectMachineFileProgramForState(state), + files: state.machineFileStaging.save.files, + executionMode: "fiveAxisRemap", + })) + .then((execution) => { + dispatch({ type: "INTERPRETER_PROGRAM_COMPLETE", sequence, execution }); + }) + .catch((error) => { + dispatch({ + type: "INTERPRETER_PROGRAM_FAILED", + sequence, + error: error instanceof Error ? error.message : String(error), + }); + }); + } catch (error) { + dispatch({ + type: "INTERPRETER_PROGRAM_FAILED", + sequence, + error: error instanceof Error ? error.message : String(error), + }); + } + } + break; + case "SESSION_SAVE_STARTED": + setState({ + sessionPersistence: { + ...state.sessionPersistence, + status: "saving", + lastError: null, + }, + operatorMessage: "saving 5-axis session", + }); + break; + case "SESSION_SAVE_COMPLETE": + setState({ + sessionPersistence: { + ...state.sessionPersistence, + status: "saved", + path: action.path, + savedAt: action.savedAt, + lastError: null, + }, + operatorMessage: `5-axis session saved ${action.path}`, + }); + break; + case "SESSION_RESTORE_STARTED": + setState({ + sessionPersistence: { + ...state.sessionPersistence, + status: "restoring", + lastError: null, + }, + operatorMessage: "restoring 5-axis session", + }); + break; + case "SESSION_RESTORE_COMPLETE": + { + const restoredProfile = getFiveAxisProfile(action.restoredState.machineProfile); + const adapter = createLinuxCncBoundaryAdapter({ + profile: restoredProfile, + runtime: { + kinematicsWasm: createKinematicsDescriptor(state.kinematicsRuntime), + interpreterWasm: createInterpreterDescriptor(state.interpreterRuntime), + }, + }); + setState({ + ...action.restoredState, + profile: restoredProfile, + linuxCncBoundaryAdapter: adapter, + linuxCncBoundaryReadiness: createLinuxCncBoundaryReadiness(adapter), + sessionPersistence: { + ...state.sessionPersistence, + status: "restored", + path: action.path, + restoredAt: action.restoredAt, + lastError: null, + }, + operatorMessage: `5-axis session restored ${action.path}`, + }); + } + break; + case "SESSION_PERSISTENCE_FAILED": + setState({ + sessionPersistence: { + ...state.sessionPersistence, + status: "error", + lastError: action.error, + }, + operatorMessage: `5-axis session error: ${action.error}`, + }); + break; + case "MACHINE_FILE_STAGING_STARTED": + setState({ + machineFileStaging: { + ...state.machineFileStaging, + status: "staging", + profileId: state.machineProfile, + lastError: null, + }, + operatorMessage: "staging LinuxCNC machine files", + }); + break; + case "MACHINE_FILE_STAGING_COMPLETE": + setState({ + machineFileStaging: { + ...state.machineFileStaging, + status: "staged", + profileId: action.plan.profileId, + fileCount: action.save.fileCount, + opfsRoot: action.save.opfsRoot, + savedAt: action.save.savedAt, + lastError: null, + plan: action.plan, + save: action.save, + gcodeSources: listLinuxCncGcodeSources(action.save), + selectedGcodeSourceRel: action.selectedGcodeSourceRel + || action.plan.selectedProgramSourceRel + || null, + }, + operatorMessage: `LinuxCNC machine files staged ${action.save.fileCount}`, + }); + break; + case "LOAD_LINUXCNC_GCODE_SOURCE": + { + const sourceRel = action.sourceRel; + const selectedFile = state.machineFileStaging?.save?.files?.find((file) => file.sourceRel === sourceRel); + if (!selectedFile) { + setState({ + operatorMessage: `LinuxCNC G-code source not staged: ${sourceRel}`, + }); + break; + } + const selectedPlan = selectMachineFileProgram( + state.machineFileStaging.plan, + state.machineFileStaging.save, + sourceRel, + ); + const loadedProgram = buildLoadedProgram({ + filename: selectedFile.sourceRel, + content: selectedFile.text, + programSource: "linuxcnc-vendored-5axis-gcode", + sourceRel: selectedFile.sourceRel, + wasmPath: selectedFile.wasmPath, + }); + setState({ + ...loadedProgram, + machineFileStaging: { + ...state.machineFileStaging, + plan: selectedPlan, + selectedGcodeSourceRel: sourceRel, + }, + machine: { + ...state.machine, + mode: "auto", + }, + axisPose: initialAxisPose, + runState: "idle", + programRuntimeFeedback: null, + preview: { + ...state.preview, + pathPoints: Math.max(loadedProgram.programLines.length, 1), + }, + operatorMessage: `loaded LinuxCNC 5-axis source ${selectedFile.sourceRel}`, + }); + if (state.interpreterRuntime?.loaded) { + dispatch({ type: "RUN_INTERPRETER_PROGRAM" }); + } + } + break; + case "MACHINE_FILE_STAGING_FAILED": + setState({ + machineFileStaging: { + ...state.machineFileStaging, + status: "error", + profileId: state.machineProfile, + lastError: action.error, + }, + operatorMessage: `machine file staging error: ${action.error}`, + }); + break; + case "SAVE_SESSION_REQUEST": + saveSession().catch(() => {}); + break; + case "RESTORE_SESSION_REQUEST": + restoreSession().catch(() => {}); + break; + case "STAGE_MACHINE_FILES_REQUEST": + stageMachineFiles(action.options || {}).catch(() => {}); + break; + case "RUN_FULL_BOUNDARY_AUDIT_REQUEST": + runFullBoundaryAudit(action.options || {}).catch(() => {}); + break; case "SET_FRAME_SOURCE": setState({ sourceMode: action.sourceMode, @@ -219,21 +736,33 @@ export function createSimulationStore(seed = {}) { }); break; case "REFRESH_KINEMATICS_FRAME": - setState({ - sourceMode: "source-derived-kinematics-wasm", - frameSourceMode: "source-derived-kinematics-wasm", - operatorMessage: "LinuxCNC kinematics frame refreshed", - }); + return refreshAsyncKinematicsFrame({ operatorMessage: "LinuxCNC kinematics frame refreshed" }); break; case "TOGGLE_POWER": - setState({ - machine: { - ...state.machine, - powerOn: !state.machine.powerOn, - }, - runState: state.machine.powerOn ? "idle" : "powered-off", - operatorMessage: state.machine.powerOn ? "machine power off" : "machine power on", - }); + { + const gate = gateLinuxCncTaskAction(state, action); + if (!gate.allowed) { + setState({ operatorMessage: gate.operatorMessage }); + break; + } + const turningOff = state.machine.taskState === "on" || state.machine.powerOn; + setState({ + machine: { + ...state.machine, + powerOn: !turningOff, + estopActive: false, + taskState: turningOff ? "estop-reset" : "on", + interpState: "idle", + interpResumeState: "idle", + taskPaused: false, + }, + runState: turningOff ? "powered-off" : "idle", + feed: turningOff ? { ...state.feed, currentVelocity: 0 } : state.feed, + coolant: turningOff ? { ...state.coolant, flood: false, mist: false } : state.coolant, + spindle: turningOff ? { ...state.spindle, enabled: false } : state.spindle, + operatorMessage: turningOff ? "machine power off" : "machine power on", + }); + } break; case "ESTOP": setState({ @@ -241,12 +770,25 @@ export function createSimulationStore(seed = {}) { ...state.machine, powerOn: false, estopActive: true, + taskState: "estop", + interpState: "idle", + interpResumeState: "idle", + taskPaused: false, }, runState: "estopped", feed: { ...state.feed, currentVelocity: 0, }, + coolant: { + ...state.coolant, + flood: false, + mist: false, + }, + spindle: { + ...state.spindle, + enabled: false, + }, operatorMessage: "emergency stop active", }); break; @@ -254,36 +796,71 @@ export function createSimulationStore(seed = {}) { setState({ machine: { ...state.machine, + powerOn: false, estopActive: false, + taskState: "estop-reset", + interpState: "idle", + interpResumeState: "idle", + taskPaused: false, resetCount: state.machine.resetCount + 1, }, runState: "idle", - operatorMessage: "machine reset complete", + coolant: { + ...state.coolant, + flood: false, + mist: false, + }, + spindle: { + ...state.spindle, + enabled: false, + }, + operatorMessage: "estop reset; machine off", }); break; case "SET_MODE": + { + const gate = gateLinuxCncTaskAction(state, action); + if (!gate.allowed) { + setState({ operatorMessage: gate.operatorMessage }); + break; + } + const mode = normalizeLinuxCncTaskMode(action.mode); + setState({ + machine: { + ...state.machine, + mode, + interpState: mode === "manual" ? "idle" : state.machine.interpState, + interpResumeState: mode === "manual" ? "idle" : state.machine.interpResumeState, + taskPaused: mode === "manual" ? false : state.machine.taskPaused, + }, + runState: mode === "manual" && state.runState === "running" ? "stopped" : state.runState, + operatorMessage: `mode ${mode}`, + }); + } + break; + case "SET_MDI_COMMAND": setState({ machine: { ...state.machine, - mode: action.mode, + mdiCommand: String(action.command ?? ""), }, - runState: state.runState === "running" ? "paused" : state.runState, - operatorMessage: `mode ${action.mode}`, + operatorMessage: "MDI command staged", }); break; case "JOG": - if (!canMoveMachine(state)) { - setState({ operatorMessage: "jog blocked: power or estop state" }); - break; - } { + const gate = gateLinuxCncTaskAction(state, action); + if (!gate.allowed) { + setState({ operatorMessage: gate.operatorMessage }); + break; + } const axis = action.axis || state.machine.jogAxis; const direction = Number(action.direction || 1); const increment = Number(action.increment || state.machine.jogIncrement); setState({ machine: { ...state.machine, - mode: "jog", + mode: "manual", jogAxis: axis, jogIncrement: increment, }, @@ -297,19 +874,15 @@ export function createSimulationStore(seed = {}) { } break; case "RUN_MDI": - if (!canMoveMachine(state)) { - setState({ operatorMessage: "MDI blocked: power or estop state" }); - break; + { + const gate = gateLinuxCncTaskAction(state, action); + if (!gate.allowed) { + setState({ operatorMessage: gate.operatorMessage }); + break; + } + const mdiResult = executeMdiCommand(state, action.command ?? state.machine.mdiCommand); + setState(mdiResult.patch); } - setState({ - machine: { - ...state.machine, - mode: "mdi", - mdiCommand: action.command || state.machine.mdiCommand, - }, - runState: "mdi", - operatorMessage: `MDI ${action.command || state.machine.mdiCommand}`, - }); break; case "LOAD_PROGRAM": { @@ -319,84 +892,230 @@ export function createSimulationStore(seed = {}) { machine: { ...state.machine, mode: "auto", + interpState: "idle", + interpResumeState: "idle", + taskPaused: false, }, axisPose: initialAxisPose, runState: "idle", + programRuntimeFeedback: null, preview: { ...state.preview, pathPoints: Math.max(loadedProgram.programLines.length, 1), }, operatorMessage: `loaded ${loadedProgram.activeProgram}`, }); + if (state.interpreterRuntime?.loaded) { + dispatch({ type: "RUN_INTERPRETER_PROGRAM" }); + } } break; case "RUN": - if (!canMoveMachine(state)) { - setState({ operatorMessage: "run blocked: power or estop state" }); - break; - } { - const nextLine = getNextProgramLine(state, 5); - const nextAxisPose = buildFixtureAxisPoseForLine(state.axisPose, nextLine); + const gate = gateLinuxCncTaskAction(state, action); + if (!gate.allowed) { + setState({ operatorMessage: gate.operatorMessage }); + break; + } + const playback = nextProgramRuntimeSamplePlayback(state, 5); setState({ machine: { ...state.machine, mode: "auto", + interpState: playback.complete ? "idle" : "reading", + interpResumeState: playback.complete ? "idle" : "reading", + taskPaused: false, }, - runState: nextLine >= getProgramEndLine(state) ? "complete" : "running", - activeLine: nextLine, - axisPose: nextAxisPose, - operatorMessage: `executing line ${nextLine}`, + runState: playback.complete ? "complete" : "running", + activeLine: playback.activeLine, + axisPose: playback.axisPose, + kinsType: playback.kinsType, + rtcpState: playback.rtcpState, + programExecutionMotionIndex: playback.motionIndex, + programExecutionSampleIndex: playback.sampleIndex, + programRuntimeFeedback: playback.runtimeFeedback, + programElapsedSeconds: playback.timing.elapsedSeconds, + programRemainingSeconds: playback.timing.remainingSeconds, + feed: { + ...state.feed, + currentVelocity: playback.timing.currentVelocity, + }, + operatorMessage: `executing line ${playback.activeLine}`, }); } break; case "STOP": - setState({ - runState: "stopped", - operatorMessage: "program stopped", - }); + case "ABORT": + { + const gate = gateLinuxCncTaskAction(state, action); + if (!gate.allowed) { + setState({ operatorMessage: gate.operatorMessage }); + break; + } + setState({ + machine: { + ...state.machine, + interpState: "idle", + interpResumeState: "idle", + taskPaused: false, + }, + runState: "stopped", + feed: { + ...state.feed, + currentVelocity: 0, + }, + operatorMessage: action.type === "ABORT" ? "task abort complete" : "program stopped", + }); + } break; case "PAUSE": - setState({ runState: "paused", operatorMessage: "program paused" }); + { + const gate = gateLinuxCncTaskAction(state, action); + if (!gate.allowed) { + setState({ operatorMessage: gate.operatorMessage }); + break; + } + setState({ + machine: { + ...state.machine, + interpResumeState: state.machine.interpState === "paused" + ? state.machine.interpResumeState + : state.machine.interpState, + interpState: "paused", + taskPaused: true, + }, + runState: "paused", + operatorMessage: "program paused", + }); + } + break; + case "RESUME": + { + const gate = gateLinuxCncTaskAction(state, action); + if (!gate.allowed) { + setState({ operatorMessage: gate.operatorMessage }); + break; + } + const resumeState = state.machine.interpResumeState === "idle" + ? "reading" + : state.machine.interpResumeState; + setState({ + machine: { + ...state.machine, + interpState: resumeState, + interpResumeState: resumeState, + taskPaused: false, + }, + runState: resumeState === "reading" ? "running" : "idle", + operatorMessage: "program resumed", + }); + } break; case "STEP": - if (!canMoveMachine(state)) { - setState({ operatorMessage: "step blocked: power or estop state" }); - break; - } { - const nextLine = getNextProgramLine(state, 1); - const nextAxisPose = buildFixtureAxisPoseForLine(state.axisPose, nextLine); + const gate = gateLinuxCncTaskAction(state, action); + if (!gate.allowed) { + setState({ operatorMessage: gate.operatorMessage }); + break; + } + const playback = nextProgramRuntimeSamplePlayback(state, 1); setState({ machine: { ...state.machine, mode: "auto", + interpResumeState: state.machine.interpState === "paused" + ? state.machine.interpResumeState + : state.machine.interpState, + interpState: "paused", + taskPaused: true, }, runState: "stepping", - activeLine: nextLine, - axisPose: nextAxisPose, - operatorMessage: `stepped to line ${nextLine}`, + activeLine: playback.activeLine, + axisPose: playback.axisPose, + kinsType: playback.kinsType, + rtcpState: playback.rtcpState, + programExecutionMotionIndex: playback.motionIndex, + programExecutionSampleIndex: playback.sampleIndex, + programRuntimeFeedback: playback.runtimeFeedback, + programElapsedSeconds: playback.timing.elapsedSeconds, + programRemainingSeconds: playback.timing.remainingSeconds, + feed: { + ...state.feed, + currentVelocity: playback.timing.currentVelocity, + }, + operatorMessage: `stepped to line ${playback.activeLine}`, }); } break; case "RUN_FRAME": - if (!canMoveMachine(state)) { - setState({ operatorMessage: "run frame blocked: power or estop state" }); - break; - } { - const nextLine = getNextProgramLine(state, 5); - const nextAxisPose = buildFixtureAxisPoseForLine(state.axisPose, nextLine); + const gate = gateLinuxCncTaskAction(state, action); + if (!gate.allowed) { + setState({ operatorMessage: gate.operatorMessage }); + break; + } + const playback = nextProgramRuntimeSamplePlayback(state, 5); setState({ + machine: { + ...state.machine, + interpState: playback.complete ? "idle" : "reading", + interpResumeState: playback.complete ? "idle" : "reading", + taskPaused: false, + }, runState: "running", - activeLine: nextLine, - axisPose: nextAxisPose, + activeLine: playback.activeLine, + axisPose: playback.axisPose, + kinsType: playback.kinsType, + rtcpState: playback.rtcpState, + programExecutionMotionIndex: playback.motionIndex, + programExecutionSampleIndex: playback.sampleIndex, + programRuntimeFeedback: playback.runtimeFeedback, + programElapsedSeconds: playback.timing.elapsedSeconds, + programRemainingSeconds: playback.timing.remainingSeconds, + feed: { + ...state.feed, + currentVelocity: playback.timing.currentVelocity, + }, }); } break; + case "HOME": + { + const gate = gateLinuxCncTaskAction(state, action); + if (!gate.allowed) { + setState({ operatorMessage: gate.operatorMessage }); + break; + } + setState({ + machine: { + ...state.machine, + mode: "manual", + allHomed: true, + interpState: "idle", + interpResumeState: "idle", + taskPaused: false, + }, + runState: "idle", + axisPose: initialAxisPose, + programRuntimeFeedback: null, + operatorMessage: "machine homed to fixture origin", + }); + } + break; + case "UNHOME": + setState({ + machine: { + ...state.machine, + allHomed: false, + }, + operatorMessage: "machine unhomed", + }); + break; case "SET_RTCP": setState({ - kinsType: action.enabled ? "tcp-xyzac" : "identity", + kinsType: action.enabled + ? (state.profile.kinematicsParameters.switchkinsTypes.find((type) => type.value === 1)?.webKinsType || "tcp-xyzac") + : "identity", rtcpState: action.enabled ? "on" : "off", }); break; @@ -427,7 +1146,7 @@ export function createSimulationStore(seed = {}) { case "SET_KINS_TYPE": setState({ kinsType: action.kinsType, - rtcpState: action.kinsType === "tcp-xyzac" ? "on" : "off", + rtcpState: action.kinsType.startsWith("tcp-") ? "on" : "off", operatorMessage: `kinematics ${action.kinsType}`, }); break; @@ -462,21 +1181,19 @@ export function createSimulationStore(seed = {}) { operatorMessage: `${action.kind} coolant toggled`, }); break; - case "HOME": - if (!canMoveMachine(state)) { - setState({ operatorMessage: "home blocked: power or estop state" }); - break; - } - setState({ - runState: "idle", - axisPose: initialAxisPose, - operatorMessage: "machine homed to fixture origin", - }); - break; case "RELOAD_PROGRAM": setState({ + machine: { + ...state.machine, + interpState: "idle", + interpResumeState: "idle", + taskPaused: false, + }, runState: "idle", activeLine: state.programStartLine === 496 ? 501 : state.programStartLine, + programExecutionMotionIndex: 0, + programExecutionSampleIndex: 0, + programRuntimeFeedback: null, axisPose: initialAxisPose, preview: { ...state.preview, pathPoints: Math.max(state.programLines.length, 1) }, operatorMessage: "program reloaded", @@ -487,6 +1204,149 @@ export function createSimulationStore(seed = {}) { } }; + const refreshAsyncKinematicsFrame = async ({ operatorMessage = state.operatorMessage } = {}) => { + if (!state.kinematicsRuntime?.loaded || !isAsyncKinematicsRuntime(state.kinematicsRuntime)) { + return state.rtcpFrame; + } + const sequence = state.asyncFrameRefreshSequence + 1; + state = { + ...state, + asyncFrameRefreshPending: true, + asyncFrameRefreshSequence: sequence, + }; + notify(); + await switchKinematicsRuntimeForState(state); + const frameSource = await state.kinematicsRuntime.frameForJoints( + jointsFromAxisPose(state.axisPose, state.profile), + { jointCount: state.kinematicsRuntime.jointCount || 5 }, + ); + if (state.asyncFrameRefreshSequence !== sequence) { + return state.rtcpFrame; + } + const frame = buildRtcpFrame({ + axisPose: state.axisPose, + activeLine: state.activeLine, + kinsType: state.kinsType, + rtcpEnabled: state.rtcpState === "on" || state.kinsType.startsWith("tcp-"), + sourceMode: "source-derived-kinematics-wasm", + profile: state.profile, + linuxCncKinematicsResult: frameSource, + }); + const nextState = { + ...state, + sourceMode: frame.sourceMode, + frameSourceMode: frame.sourceMode, + axisPose: frame.axisPose, + jointPose: frame.jointPose, + tcpPose: frame.tcpPose, + toolAxisVector: frame.toolAxisVector, + rtcpState: frame.rtcpState, + rtcpFrame: frame, + lastKinematicsResult: frameSource, + dro: buildDroFromFrame(frame, state.programRuntimeFeedback), + asyncFrameRefreshPending: false, + operatorMessage, + }; + state = { + ...nextState, + fullExecutionBoundary: createFullLinuxCncExecutionBoundary(nextState), + }; + notify(); + return frame; + }; + + const saveSession = async (options = {}) => { + dispatch({ type: "SESSION_SAVE_STARTED" }); + try { + const sessionId = options.sessionId || state.sessionPersistence.sessionId; + const filename = options.filename || state.sessionPersistence.filename; + const payload = createFiveAxisSessionPayload(state); + const { snapshot, path } = await saveFiveAxisSessionSnapshot(sessionId, payload, { + filename, + storage: options.storage, + metadata: options.metadata, + }); + dispatch({ + type: "SESSION_SAVE_COMPLETE", + path, + savedAt: snapshot.createdAt, + }); + return { snapshot, path }; + } catch (error) { + dispatch({ type: "SESSION_PERSISTENCE_FAILED", error: error.message }); + throw error; + } + }; + + const restoreSession = async (options = {}) => { + dispatch({ type: "SESSION_RESTORE_STARTED" }); + try { + const sessionId = options.sessionId || state.sessionPersistence.sessionId; + const filename = options.filename || state.sessionPersistence.filename; + const { snapshot, path } = await loadFiveAxisSessionSnapshot(sessionId, { + filename, + storage: options.storage, + }); + dispatch({ + type: "SESSION_RESTORE_COMPLETE", + restoredState: restoreFiveAxisSessionState(snapshot), + path, + restoredAt: new Date().toISOString(), + }); + await refreshAsyncKinematicsFrame({ operatorMessage: `5-axis session restored ${path}` }); + return { snapshot, path }; + } catch (error) { + dispatch({ type: "SESSION_PERSISTENCE_FAILED", error: error.message }); + throw error; + } + }; + + const stageMachineFiles = async (options = {}) => { + dispatch({ type: "MACHINE_FILE_STAGING_STARTED" }); + try { + const { plan, save } = await stageProfileMachineFiles(state.profile, { + ...options, + iniText: options.iniText || state.linuxCncIniConfig?.sourceText, + }); + dispatch({ type: "MACHINE_FILE_STAGING_COMPLETE", plan, save }); + return { plan, save }; + } catch (error) { + dispatch({ + type: "MACHINE_FILE_STAGING_FAILED", + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + }; + + const runFullBoundaryAudit = async (options = {}) => { + if (state.machineFileStaging?.status !== "staged" || options.restage === true) { + await stageMachineFiles(options); + } + if (!state.machineFileStaging.selectedGcodeSourceRel) { + const sourceRel = options.sourceRel || defaultLinuxCncGcodeSourceForState(state)?.sourceRel; + if (sourceRel) { + dispatch({ type: "LOAD_LINUXCNC_GCODE_SOURCE", sourceRel }); + await Promise.resolve(); + } + } + dispatch({ type: "RUN_MACHINE_FILE_PROGRAM" }); + }; + + const scheduleAsyncKinematicsRefresh = () => { + if (!state.kinematicsRuntime?.loaded || !isAsyncKinematicsRuntime(state.kinematicsRuntime)) return null; + if (state.frameSourceMode !== "source-derived-kinematics-wasm") return null; + const frame = state.rtcpFrame; + if ( + frame?.sourceMode === "source-derived-kinematics-wasm" && + frame.readiness?.linuxCncKinematicsReady === true && + frame.activeLine === state.activeLine + ) { + return null; + } + return refreshAsyncKinematicsFrame(); + }; + return { getState: () => state, subscribe(listener) { @@ -495,6 +1355,11 @@ export function createSimulationStore(seed = {}) { return () => listeners.delete(listener); }, dispatch, + refreshKinematicsFrame: refreshAsyncKinematicsFrame, + saveSession, + restoreSession, + stageMachineFiles, + runFullBoundaryAudit, }; } @@ -505,9 +1370,10 @@ function buildFrameForState(state, patch = {}) { let operatorMessage = state.operatorMessage; if (requestedSourceMode === "source-derived-kinematics-wasm") { - if (state.kinematicsRuntime?.loaded) { + if (state.kinematicsRuntime?.loaded && !isAsyncKinematicsRuntime(state.kinematicsRuntime)) { + switchKinematicsRuntimeForState(state); linuxCncKinematicsResult = state.kinematicsRuntime.frameForJoints( - jointsFromAxisPose(state.axisPose), + jointsFromAxisPose(state.axisPose, state.profile), { jointCount: state.kinematicsRuntime.jointCount || 5 }, ); } else { @@ -521,7 +1387,7 @@ function buildFrameForState(state, patch = {}) { axisPose: state.axisPose, activeLine: state.activeLine, kinsType: state.kinsType, - rtcpEnabled: state.rtcpState === "on" || state.kinsType === "tcp-xyzac", + rtcpEnabled: state.rtcpState === "on" || state.kinsType.startsWith("tcp-"), sourceMode, profile: state.profile, linuxCncKinematicsResult, @@ -537,8 +1403,310 @@ function buildFrameForState(state, patch = {}) { }; } +function createKinematicsDescriptor(runtime) { + if (!runtime?.loaded) return null; + return { + apiName: runtime.apiName, + moduleId: runtime.moduleId, + wasmFile: runtime.wasmFile, + supportedModules: runtime.supportedModules, + loaded: runtime.loaded, + sourceMode: runtime.sourceMode, + semanticBoundary: runtime.semanticBoundary, + executionContext: runtime.executionContext || "direct", + workerUrl: runtime.workerUrl || null, + switchkinsType: runtime.switchkinsType, + switchRc: runtime.switchRc, + }; +} + +function createInterpreterDescriptor(runtime) { + if (!runtime?.loaded) return null; + return { + apiName: runtime.apiName, + loaded: runtime.loaded, + sourceMode: runtime.sourceMode, + semanticBoundary: runtime.semanticBoundary, + executionContext: runtime.executionContext || "direct", + }; +} + +function selectMachineFileProgramForState(state) { + const sourceRel = state.machineFileStaging?.selectedGcodeSourceRel; + if (!sourceRel) return state.machineFileStaging.plan; + return selectMachineFileProgram( + state.machineFileStaging.plan, + state.machineFileStaging.save, + sourceRel, + ); +} + +function defaultLinuxCncGcodeSourceForState(state) { + const sources = state.machineFileStaging?.gcodeSources || []; + const preferred = `${state.machineProfile || "xyzac-trt"}_switchkins.ngc`; + return sources.find((source) => source.filename === preferred) + || sources.find((source) => source.filename.includes(state.machineProfile || "xyzac")) + || sources[0] + || null; +} + +function isAsyncKinematicsRuntime(runtime) { + return runtime?.executionContext === "worker"; +} + function canMoveMachine(state) { - return state.machine.powerOn && !state.machine.estopActive; + return createLinuxCncTaskPolicyStatus(state).canMove; +} + +function normalizeMachineForLinuxCncTask(machine = {}, runState = "idle") { + const taskState = machine.estopActive + ? "estop" + : machine.taskState === "on" || machine.powerOn + ? "on" + : machine.taskState === "off" + ? "off" + : "estop-reset"; + const mode = normalizeLinuxCncTaskMode(machine.mode); + const interpState = machine.interpState + || (runState === "running" ? "reading" : runState === "paused" || runState === "stepping" ? "paused" : "idle"); + + return { + ...machine, + taskState, + mode, + interpState, + interpResumeState: machine.interpResumeState || (interpState === "paused" ? "reading" : interpState), + taskPaused: Boolean(machine.taskPaused || interpState === "paused"), + powerOn: taskState === "on", + estopActive: taskState === "estop", + allHomed: Boolean(machine.allHomed), + noForceHoming: Boolean(machine.noForceHoming), + }; +} + +function createIniConfigReadiness(iniConfig) { + return { + apiName: "web-rtcp-5axis-ini-config-readiness", + loaded: true, + ready: iniConfig.validation.ready, + path: iniConfig.path, + missing: iniConfig.validation.missing, + machineName: iniConfig.machineName, + coordinates: iniConfig.traj.coordinates, + kinematics: iniConfig.kinematics.name, + axisCount: iniConfig.validation.axisCount, + jointCount: iniConfig.validation.jointCount, + semanticBoundary: iniConfig.semanticBoundary, + }; +} + +function normalizeKinsTypeForProfile(kinsType, profile) { + if (kinsType !== "tcp-xyzac" && kinsType !== "tcp-xyzbc") return kinsType; + return profile.kinematicsParameters.switchkinsTypes.find((type) => type.value === 1)?.webKinsType || kinsType; +} + +function clampAxisPoseToProfile(axisPose, profile = defaultProfile) { + const next = { ...axisPose }; + for (const [axis, limits] of Object.entries(profile.axisLimits || {})) { + const key = axis.toLowerCase(); + const value = Number(next[key] ?? 0); + const min = Number.isFinite(limits.min) ? limits.min : -Infinity; + const max = Number.isFinite(limits.max) ? limits.max : Infinity; + next[key] = Math.min(Math.max(value, min), max); + } + return next; +} + +function executeMdiCommand(state, rawCommand) { + const command = normalizeMdiCommand(rawCommand); + if (!command) { + return { + patch: { + operatorMessage: "MDI blocked: empty command", + }, + }; + } + + const parsed = parseMdiCommand(command); + const parsedKinsType = resolveMdiKinsType(state, parsed.kinsType); + const distanceMode = parsed.distanceMode || state.machine.mdiDistanceMode || "absolute"; + const nextAxisPose = { ...state.axisPose }; + for (const axis of ["x", "y", "z", "a", "b", "c"]) { + if (!Number.isFinite(parsed.axes[axis])) continue; + nextAxisPose[axis] = distanceMode === "relative" + ? Number(nextAxisPose[axis] || 0) + parsed.axes[axis] + : parsed.axes[axis]; + } + + const nextMachine = { + ...state.machine, + mode: "mdi", + mdiCommand: command, + mdiDistanceMode: distanceMode, + interpState: "idle", + interpResumeState: "idle", + taskPaused: false, + }; + const mdiExecution = createMdiProgramExecution(command, nextAxisPose, parsed.motionCode); + const mdiFeed = parsed.feedRate !== null + ? { ...state.feed, feedRate: parsed.feedRate, currentVelocity: parsed.feedRate } + : state.feed; + const mdiTiming = buildProgramExecutionTiming({ + motion: mdiExecution.motion, + profile: state.profile, + feedOverride: mdiFeed.feedOverride, + rapidOverride: mdiFeed.rapidOverride, + defaultFeedRate: mdiFeed.feedRate, + }); + const mdiTimingSnapshot = timingAtMotionIndex(mdiTiming, 0); + const patch = { + machine: nextMachine, + runState: "mdi", + axisPose: nextAxisPose, + activeProgram: "MDI", + programSource: "operator-mdi", + programStartLine: 1, + activeLine: 1, + lineCount: 1, + fileSizeBytes: command.length, + programLines: [command], + programExecution: mdiExecution, + programExecutionTiming: mdiTiming, + programExecutionSourceMode: "operator-mdi", + programExecutionMotionIndex: 0, + programElapsedSeconds: mdiTimingSnapshot.elapsedSeconds, + programRemainingSeconds: mdiTimingSnapshot.remainingSeconds, + preview: { + ...state.preview, + pathPoints: hasMdiAxisWords(parsed) ? Math.max(state.preview.pathPoints, 2) : state.preview.pathPoints, + }, + feed: { ...mdiFeed, currentVelocity: mdiTimingSnapshot.currentVelocity || mdiFeed.currentVelocity }, + spindle: parsed.spindleRpm !== null || parsed.spindleEnabled !== null + ? { + ...state.spindle, + rpm: parsed.spindleRpm ?? state.spindle.rpm, + enabled: parsed.spindleEnabled ?? state.spindle.enabled, + } + : state.spindle, + coolant: parsed.coolantPatch + ? { ...state.coolant, ...parsed.coolantPatch } + : state.coolant, + kinsType: parsedKinsType || state.kinsType, + rtcpState: parsedKinsType?.startsWith("tcp-") ? "on" : parsedKinsType ? "off" : state.rtcpState, + mdiHistory: [command, ...(state.mdiHistory || []).filter((entry) => entry !== command)].slice(0, 8), + operatorMessage: `MDI ${command}`, + }; + + return { patch }; +} + +function normalizeMdiCommand(command) { + return String(command || "") + .replace(/\([^)]*\)/g, " ") + .replace(/;.*$/g, " ") + .trim() + .replace(/\s+/g, " ") + .toUpperCase(); +} + +function parseMdiCommand(command) { + const parsed = { + axes: {}, + feedRate: null, + spindleRpm: null, + spindleEnabled: null, + coolantPatch: null, + distanceMode: null, + motionCode: null, + kinsType: null, + }; + const words = [...command.matchAll(/([A-Z])\s*([-+]?\d+(?:\.\d+)?)/g)] + .map((match) => ({ letter: match[1], value: Number(match[2]) })); + + for (const word of words) { + if (["X", "Y", "Z", "A", "B", "C"].includes(word.letter)) { + parsed.axes[word.letter.toLowerCase()] = word.value; + continue; + } + if (word.letter === "F") { + parsed.feedRate = Math.max(0, word.value); + continue; + } + if (word.letter === "S") { + parsed.spindleRpm = Math.max(0, word.value); + continue; + } + if (word.letter === "G") { + if (word.value === 90) parsed.distanceMode = "absolute"; + if (word.value === 91) parsed.distanceMode = "relative"; + if ([0, 1, 2, 3].includes(word.value)) parsed.motionCode = `G${word.value}`; + continue; + } + if (word.letter === "M") { + applyMdiMCode(parsed, word.value); + } + } + + return parsed; +} + +function applyMdiMCode(parsed, value) { + if (value === 3 || value === 4) { + parsed.spindleEnabled = true; + } else if (value === 5) { + parsed.spindleEnabled = false; + } else if (value === 7) { + parsed.coolantPatch = { ...(parsed.coolantPatch || {}), mist: true }; + } else if (value === 8) { + parsed.coolantPatch = { ...(parsed.coolantPatch || {}), flood: true }; + } else if (value === 9) { + parsed.coolantPatch = { flood: false, mist: false }; + } else if (value === 428) { + parsed.kinsType = "tcp"; + } else if (value === 429) { + parsed.kinsType = "identity"; + } else if (value === 430) { + parsed.kinsType = "userk"; + } +} + +function resolveMdiKinsType(state, kinsType) { + if (kinsType !== "tcp") return kinsType; + return state.profile.kinematicsParameters.switchkinsTypes.find((type) => type.value === 1)?.webKinsType || "tcp-xyzac"; +} + +function hasMdiAxisWords(parsed) { + return Object.values(parsed.axes).some((value) => Number.isFinite(value)); +} + +function createMdiProgramExecution(command, axisPose, motionCode) { + const motionType = motionCode === "G0" ? "STRAIGHT_TRAVERSE" : "STRAIGHT_FEED"; + return { + apiName: "web-rtcp-5axis-mdi-execution", + sourceMode: "operator-mdi", + semanticBoundary: "operator_mdi_lightweight_motion_words", + resultText: `mdi_command=${command}`, + motion: hasMdiAxisWords(parseMdiCommand(command)) + ? [{ + type: motionType, + line: 1, + statement: command, + axes: { ...axisPose }, + raw: `mdi_command=${command}`, + }] + : [], + summary: { + ready: true, + programLineCount: 1, + canonicalEventCount: 1, + motionEventCount: hasMdiAxisWords(parseMdiCommand(command)) ? 1 : 0, + motionTypes: hasMdiAxisWords(parseMdiCommand(command)) ? [motionType] : [], + finalAxes: { ...axisPose }, + remapRuntimeReady: false, + plannerRuntimeReady: false, + fullLinuxCncProgramExecutionReady: false, + }, + }; } function getProgramEndLine(state) { @@ -549,13 +1717,212 @@ function getNextProgramLine(state, step) { return Math.min(state.activeLine + step, getProgramEndLine(state)); } +function nextProgramPlayback(state, step) { + if (state.programExecution?.motion?.length > 0) { + const timing = state.programExecutionTiming || buildTimingForState(state, state.programExecution); + const motionIndex = Math.min( + Number(state.programExecutionMotionIndex || 0) + step, + state.programExecution.motion.length - 1, + ); + const motion = state.programExecution.motion[motionIndex]; + const timingSnapshot = timingAtMotionIndex(timing, motionIndex); + const kinsType = kinsTypeFromProgramMotion(state, motion) || state.kinsType; + return { + motionIndex, + activeLine: motion.line || state.activeLine, + axisPose: axisPoseFromCanonicalMotion(motion, state.axisPose), + kinsType, + rtcpState: rtcpStateFromKinsType(kinsType), + timing: timingSnapshot, + complete: motionIndex >= state.programExecution.motion.length - 1, + }; + } + + const activeLine = getNextProgramLine(state, step); + return { + motionIndex: state.programExecutionMotionIndex || 0, + activeLine, + axisPose: buildFixtureAxisPoseForLine(state.axisPose, activeLine), + kinsType: state.kinsType, + rtcpState: state.rtcpState, + timing: { + elapsedSeconds: 0, + remainingSeconds: 0, + currentVelocity: state.feed.currentVelocity, + segmentDurationSeconds: 0, + segmentDistanceMm: 0, + }, + complete: activeLine >= getProgramEndLine(state), + }; +} + +function nextProgramRuntimeSamplePlayback(state, step) { + const timing = state.programExecutionTiming || buildTimingForState(state, state.programExecution); + const samples = Array.isArray(timing?.samples) ? timing.samples : []; + if (samples.length > 0) { + const sampleIndex = Math.min( + Number(state.programExecutionSampleIndex || 0) + Math.max(Number(step) || 1, 1), + samples.length - 1, + ); + const sample = samples[sampleIndex]; + const motionIndex = clampMotionIndex(state, sample.motionIndex); + const motion = state.programExecution?.motion?.[motionIndex] || null; + const segment = timing?.segments?.[motionIndex] || null; + const kinsType = kinsTypeFromProgramMotion(state, motion) || state.kinsType; + const elapsedSeconds = Number(sample.timeSeconds) || Number(segment?.elapsedSeconds) || 0; + const currentVelocity = Number(sample.currentVelocityMmPerMin) + || Number(sample.currentVelocity) * 60 + || Number(segment?.velocityMmPerMin) + || 0; + const runtimeFeedback = createProgramRuntimeFeedbackFromSample({ + state, + sample, + sampleIndex, + motion, + motionIndex, + segment, + currentVelocity, + elapsedSeconds, + }); + return { + motionIndex, + sampleIndex, + activeLine: sample.line || motion?.line || state.activeLine, + axisPose: axisPoseFromRuntimeSample(sample, motion, state.axisPose), + kinsType, + rtcpState: rtcpStateFromKinsType(kinsType), + timing: { + elapsedSeconds, + remainingSeconds: Math.max((timing?.totalSeconds || 0) - elapsedSeconds, 0), + currentVelocity, + segmentDurationSeconds: Number(segment?.durationSeconds) || 0, + segmentDistanceMm: Number(segment?.linearDistanceMm) || 0, + }, + runtimeFeedback, + complete: sampleIndex >= samples.length - 1, + }; + } + + const playback = nextProgramPlayback(state, step); + return { + ...playback, + sampleIndex: playback.motionIndex, + runtimeFeedback: createProgramRuntimeFeedbackFromMotion({ + state, + motion: state.programExecution?.motion?.[playback.motionIndex] || null, + motionIndex: playback.motionIndex, + timing: playback.timing, + sourceMode: state.programExecution?.sourceMode === "linuxcnc-interpreter-wasm" + ? "linuxcnc-canonical-motion" + : "fixture-line-playback", + }), + }; +} + +function createInitialProgramRuntimeFeedback({ state, timing, motion, timingSnapshot }) { + const firstSample = timing?.samples?.[0] || null; + if (firstSample) { + return createProgramRuntimeFeedbackFromSample({ + state, + sample: firstSample, + sampleIndex: 0, + motion, + motionIndex: clampMotionIndex(state, firstSample.motionIndex), + segment: timing?.segments?.[0] || null, + currentVelocity: Number(firstSample.currentVelocityMmPerMin) + || Number(firstSample.currentVelocity) * 60 + || 0, + elapsedSeconds: Number(firstSample.timeSeconds) || 0, + }); + } + return createProgramRuntimeFeedbackFromMotion({ + state, + motion, + motionIndex: 0, + timing: timingSnapshot, + sourceMode: "linuxcnc-canonical-motion", + }); +} + +function clampMotionIndex(state, motionIndex) { + const count = state.programExecution?.motion?.length || 0; + if (count <= 0) return 0; + const index = Number(motionIndex); + return Number.isFinite(index) ? Math.min(Math.max(index, 0), count - 1) : 0; +} + +function buildTimingForState(state, execution) { + if (execution?.plannerTiming?.plannerRuntimeReady === true) { + return execution.plannerTiming; + } + return buildProgramExecutionTiming({ + motion: execution?.motion || [], + profile: state.profile, + feedOverride: state.feed.feedOverride, + rapidOverride: state.feed.rapidOverride, + defaultFeedRate: state.feed.feedRate, + }); +} + +function kinsTypeFromProgramMotion(state, motion) { + if (!motion) return null; + if (motion.kinsType) { + return resolveProgramKinsType(state, motion.kinsType); + } + if (Number.isFinite(motion.switchkinsType)) { + return kinsTypeFromSwitchkinsType(state, motion.switchkinsType); + } + return null; +} + +function resolveProgramKinsType(state, requestedKinsType) { + if (requestedKinsType === "tcp") { + return kinsTypeFromSwitchkinsType(state, 1); + } + if (requestedKinsType === "identity") { + return "identity"; + } + if (requestedKinsType === "userk") { + return kinsTypeFromSwitchkinsType(state, 2); + } + return requestedKinsType || null; +} + +function kinsTypeFromSwitchkinsType(state, switchkinsType) { + return state.profile.kinematicsParameters.switchkinsTypes + .find((type) => type.value === switchkinsType)?.webKinsType || null; +} + +function switchkinsTypeFromKinsType(state) { + const match = state.profile.kinematicsParameters.switchkinsTypes + .find((type) => type.webKinsType === state.kinsType); + return Number.isFinite(match?.value) ? match.value : 0; +} + +function rtcpStateFromKinsType(kinsType) { + return String(kinsType || "").startsWith("tcp-") ? "on" : "off"; +} + +function switchKinematicsRuntimeForState(state) { + if (!state.kinematicsRuntime?.loaded || typeof state.kinematicsRuntime.switchKinematics !== "function") { + return null; + } + const switchkinsType = switchkinsTypeFromKinsType(state); + if (state.kinematicsRuntime.switchkinsType === switchkinsType) { + return state.kinematicsRuntime.switchRc ?? 0; + } + return state.kinematicsRuntime.switchKinematics(switchkinsType); +} + function buildLoadedProgram(action) { const content = String(action.content || ""); const lines = parseProgramLines(content); const filename = action.filename || "operator-program.ngc"; return { activeProgram: filename, - programSource: "operator-file", + programSource: action.programSource || "operator-file", + programSourceRel: action.sourceRel || null, + programWasmPath: action.wasmPath || null, programStartLine: 1, activeLine: 1, lineCount: lines.length, @@ -576,7 +1943,8 @@ function clampPercent(value, min, max) { return Math.min(Math.max(value, min), max); } -function buildDroFromFrame(frame) { +function buildDroFromFrame(frame, runtimeFeedback = null) { + const dtg = runtimeFeedback?.dtg || null; return { x: frame.axisPose.x, y: frame.axisPose.y, @@ -587,18 +1955,19 @@ function buildDroFromFrame(frame) { tcpX: frame.tcpPose.x, tcpY: frame.tcpPose.y, tcpZ: frame.tcpPose.z, - dtgX: frame.rtcpEnabled ? Math.abs(frame.compensation.x) : 0, - dtgY: frame.rtcpEnabled ? Math.abs(frame.compensation.y) : 0.01, - dtgZ: frame.rtcpEnabled ? Math.abs(frame.compensation.z) : 2.25, + dtgX: Number.isFinite(dtg?.x) ? dtg.x : frame.rtcpEnabled ? Math.abs(frame.compensation.x) : 0, + dtgY: Number.isFinite(dtg?.y) ? dtg.y : frame.rtcpEnabled ? Math.abs(frame.compensation.y) : 0.01, + dtgZ: Number.isFinite(dtg?.z) ? dtg.z : frame.rtcpEnabled ? Math.abs(frame.compensation.z) : 2.25, }; } -function jointsFromAxisPose(axisPose) { +function jointsFromAxisPose(axisPose, profile = defaultProfile) { + const fourthAxis = profile.traj?.coordinates?.includes("B") ? "b" : "a"; return [ Number(axisPose.x || 0), Number(axisPose.y || 0), Number(axisPose.z || 0), - Number(axisPose.a || 0), + Number(axisPose[fourthAxis] || 0), Number(axisPose.c || 0), ]; } @@ -614,3 +1983,104 @@ function buildFixtureAxisPoseForLine(axisPose, line) { c: Math.cos(phase * 0.33) * 32, }; } + +function axisPoseFromCanonicalMotion(motion, fallbackPose) { + const axes = motion?.axes || {}; + return { + x: Number(axes.x ?? fallbackPose.x ?? 0), + y: Number(axes.y ?? fallbackPose.y ?? 0), + z: Number(axes.z ?? fallbackPose.z ?? 0), + a: Number(axes.a ?? fallbackPose.a ?? 0), + b: Number(axes.b ?? fallbackPose.b ?? 0), + c: Number(axes.c ?? fallbackPose.c ?? 0), + }; +} + +function axisPoseFromRuntimeSample(sample, motion, fallbackPose) { + const sampleAxes = sample?.axes || {}; + const canonicalAxes = motion?.axes || {}; + return { + x: numberOrFallback(sampleAxes.x, canonicalAxes.x, fallbackPose.x, 0), + y: numberOrFallback(sampleAxes.y, canonicalAxes.y, fallbackPose.y, 0), + z: numberOrFallback(sampleAxes.z, canonicalAxes.z, fallbackPose.z, 0), + a: numberOrFallback(sampleAxes.a, canonicalAxes.a, fallbackPose.a, 0), + b: numberOrFallback(sampleAxes.b, canonicalAxes.b, fallbackPose.b, 0), + c: numberOrFallback(sampleAxes.c, canonicalAxes.c, fallbackPose.c, 0), + }; +} + +function createProgramRuntimeFeedbackFromSample({ + state, + sample, + sampleIndex, + motion, + motionIndex, + segment, + currentVelocity, + elapsedSeconds, +}) { + const axisPose = axisPoseFromRuntimeSample(sample, motion, state.axisPose); + return { + apiName: "web-rtcp-5axis-program-runtime-feedback", + sourceMode: "linuxcnc-tp-runtime-sample", + semanticBoundary: "linuxcnc_tp_run_cycle_feedback_without_hardware", + sampleIndex, + motionIndex, + line: sample?.line || motion?.line || null, + type: sample?.type || motion?.type || null, + timeSeconds: elapsedSeconds, + axisPose, + currentVelocityMmPerMin: currentVelocity, + requestedVelocityMmPerMin: Number(sample?.requestedVelocityMmPerMin) + || Number(sample?.requestedVelocity) * 60 + || Number(segment?.velocityMmPerMin) + || 0, + distanceToGo: Number(sample?.distanceToGo) || 0, + dtg: { + x: Number(sample?.dtg?.x) || 0, + y: Number(sample?.dtg?.y) || 0, + z: Number(sample?.dtg?.z) || 0, + }, + queueDepth: Number(sample?.queueDepth) || 0, + activeDepth: Number(sample?.activeDepth) || 0, + cycle: Number(sample?.cycle) || 0, + }; +} + +function createProgramRuntimeFeedbackFromMotion({ + state, + motion, + motionIndex, + timing, + sourceMode, +}) { + const axisPose = axisPoseFromCanonicalMotion(motion, state.axisPose); + return { + apiName: "web-rtcp-5axis-program-runtime-feedback", + sourceMode, + semanticBoundary: sourceMode === "fixture-line-playback" + ? "fixture_line_playback_feedback" + : "linuxcnc_canonical_motion_feedback_without_tp_sample", + sampleIndex: motionIndex, + motionIndex, + line: motion?.line || null, + type: motion?.type || null, + timeSeconds: Number(timing?.elapsedSeconds) || 0, + axisPose, + currentVelocityMmPerMin: Number(timing?.currentVelocity) || 0, + requestedVelocityMmPerMin: Number(timing?.currentVelocity) || 0, + distanceToGo: 0, + dtg: { x: 0, y: 0, z: 0 }, + queueDepth: 0, + activeDepth: 0, + cycle: 0, + }; +} + +function numberOrFallback(...values) { + for (const value of values) { + const number = Number(value); + if (Number.isFinite(number)) return number; + } + return 0; +} diff --git a/web-rtcp-5axis-sim-plan/app/src/styles/gmoccapy.css b/web-rtcp-5axis-sim-plan/app/src/styles/gmoccapy.css index 1c41385..e437228 100644 --- a/web-rtcp-5axis-sim-plan/app/src/styles/gmoccapy.css +++ b/web-rtcp-5axis-sim-plan/app/src/styles/gmoccapy.css @@ -9,6 +9,9 @@ --green-dark: #02bf19; --black: #050505; --text: #2e2e2e; + --ink: #242424; + --line: #aaa59b; + --muted: #5e5a52; font-family: Arial, Helvetica, sans-serif; } @@ -19,11 +22,11 @@ html, body { width: 100%; - min-width: 1024px; - min-height: 768px; + min-width: 1180px; + min-height: 640px; height: 100%; margin: 0; - overflow: auto; + overflow: hidden; background: #c8c4bc; color: var(--text); } @@ -40,26 +43,44 @@ button:active { 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 { display: grid; grid-template-columns: - minmax(350px, 0.76fr) - minmax(160px, 0.36fr) - minmax(128px, 0.28fr) - 78px - minmax(176px, 0.4fr) - 104px; - grid-template-rows: 28px minmax(156px, 0.36fr) minmax(240px, 0.64fr) 224px 62px; + minmax(610px, 1.34fr) + minmax(270px, 0.58fr) + minmax(260px, 0.56fr) + 108px; + grid-template-rows: 40px minmax(210px, 1fr) minmax(170px, 0.78fr) 150px 68px; grid-template-areas: - "title title title title title title" - "preview preview dro dro dro side" - "preview preview gcode gcode gcode side" - "info override override spindle spindle side" - "bottom bottom bottom bottom bottom side"; + "title title title title" + "preview dro dro side" + "preview gcode gcode side" + "info override spindle side" + "bottom bottom bottom side"; width: 100vw; height: 100vh; - min-width: 1024px; - min-height: 768px; + min-width: 1180px; + min-height: 640px; border: 1px solid var(--border); background: var(--panel); } @@ -70,7 +91,7 @@ button:active { align-items: center; gap: 10px; min-width: 0; - padding: 2px 10px; + padding: 4px 12px; background: #cfcbc3; border-bottom: 1px solid var(--border); } @@ -78,8 +99,9 @@ button:active { .brand-dot { display: grid; place-items: center; - width: 22px; - height: 22px; + flex: 0 0 auto; + width: 26px; + height: 26px; border: 2px solid #ffcf00; border-radius: 50%; color: #e21d1d; @@ -98,7 +120,7 @@ button:active { .title-stack strong { overflow: hidden; - font-size: 14px; + font-size: 17px; text-overflow: ellipsis; white-space: nowrap; } @@ -106,7 +128,7 @@ button:active { .title-stack span, .run-state { overflow: hidden; - font-size: 12px; + font-size: 14px; color: #4d4d4d; text-overflow: ellipsis; white-space: nowrap; @@ -140,8 +162,9 @@ button:active { .machine-preview { width: 100%; - height: calc(100% - 54px); - margin-top: 24px; + height: calc(100% - 64px); + margin-top: 32px; + display: block; } .tool-preview-card { @@ -215,7 +238,7 @@ button:active { .rtcp-preview-badge { position: absolute; right: 8px; - bottom: 58px; + bottom: 62px; left: 8px; z-index: 2; overflow: hidden; @@ -242,8 +265,8 @@ button:active { left: 0; display: grid; grid-template-columns: repeat(5, 1fr); - gap: 4px; - padding: 4px; + gap: 6px; + padding: 6px; background: var(--panel); border-top: 1px solid var(--border); } @@ -251,7 +274,7 @@ button:active { .preview-toolbar button, .bottom-controls button, .status-sidebar button { - min-height: 46px; + min-height: 42px; font-weight: 700; } @@ -262,7 +285,8 @@ button:active { min-width: 0; min-height: 0; overflow: hidden; - border-bottom: 1px solid #151515; + border-left: 2px solid #8d887f; + border-bottom: 2px solid #8d887f; background: var(--black); } @@ -277,17 +301,17 @@ button:active { .dro-row { display: grid; - grid-template-columns: 24px 40px minmax(74px, 1fr) 48px; + grid-template-columns: 30px 46px minmax(92px, 1fr) 58px; align-items: center; min-width: 0; min-height: 0; - padding: 3px 5px; + padding: 4px 7px; background: var(--black); color: var(--green); } .dro-axis { - font-size: 22px; + font-size: clamp(24px, 2vw, 32px); font-weight: 800; line-height: 1; } @@ -295,14 +319,14 @@ button:active { .dro-mode, .dro-dtg { color: var(--green); - font-size: 9px; + font-size: 10px; line-height: 1.25; } .dro-row strong { overflow: hidden; text-align: right; - font-size: clamp(20px, 2.4vw, 30px); + font-size: clamp(24px, 2.5vw, 38px); line-height: 1; font-variant-numeric: tabular-nums; text-overflow: clip; @@ -314,10 +338,10 @@ button:active { gap: 6px; justify-content: space-between; min-width: 0; - padding: 5px 8px; + padding: 6px 9px; background: #080808; color: var(--green); - font-size: 12px; + font-size: 13px; font-weight: 700; } @@ -343,11 +367,11 @@ button:active { .gcode-panel { grid-area: gcode; display: grid; - grid-template-rows: 30px minmax(0, 1fr) 22px; + grid-template-rows: 32px minmax(0, 1fr) 20px 54px; min-width: 0; min-height: 0; overflow: hidden; - background: #f4f2ed; + background: #f6f4ef; border-top: 2px solid var(--border); border-bottom: 2px solid var(--border); } @@ -358,10 +382,10 @@ button:active { gap: 8px; align-items: center; min-width: 0; - padding: 5px 8px; + padding: 6px 9px; border-bottom: 1px solid #d5d0c7; background: #eeeae3; - font-size: 12px; + font-size: 13px; } .gcode-header strong, @@ -380,27 +404,32 @@ button:active { .gcode-list { min-height: 0; margin: 0; - padding: 4px 6px 2px; + padding: 6px 8px 3px; overflow: auto; list-style: none; font-family: "Courier New", monospace; - font-size: 15px; - color: #8b8b8b; + font-size: 13px; + color: #55514a; } .gcode-row { display: grid; grid-template-columns: 42px minmax(0, 1fr); gap: 6px; - min-height: 20px; - line-height: 1.35; + min-height: 18px; + line-height: 1.25; } .gcode-row.active { - background: #e8e8e8; + background: #242424; color: #202020; } +.gcode-row.active span, +.gcode-row.active code { + color: #f2f2f2; +} + .gcode-row code { overflow: hidden; white-space: nowrap; @@ -412,8 +441,8 @@ button:active { grid-template-columns: 120px 1fr; align-items: center; gap: 8px; - padding: 2px 8px 4px; - font-size: 12px; + padding: 1px 8px 3px; + font-size: 11px; color: #777; } @@ -428,10 +457,70 @@ button:active { 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 { grid-area: side; 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; padding: 6px; border-left: 2px solid var(--border); @@ -475,7 +564,7 @@ button:active { grid-area: info; min-width: 0; min-height: 0; - overflow: hidden; + overflow: auto; border-top: 2px solid var(--border); border-right: 2px solid var(--border); background: #eeeae3; @@ -492,9 +581,9 @@ button:active { border-right: 1px solid var(--border); border-radius: 0; background: #e0ddd6; - min-height: 37px; + min-height: 32px; padding: 4px 5px; - font-size: 14px; + font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -507,10 +596,10 @@ button:active { .info-grid { display: grid; grid-template-columns: max-content 1fr; - gap: 3px 10px; - margin: 8px; - font-size: 14px; - line-height: 1.22; + gap: 1px 8px; + margin: 6px 8px; + font-size: 12px; + line-height: 1.15; } .info-grid dt { @@ -533,7 +622,7 @@ button:active { grid-area: override; display: grid; grid-template-columns: 1fr; - grid-template-rows: 56px 64px minmax(78px, 1fr); + grid-template-rows: repeat(3, minmax(0, 1fr)); gap: 6px; padding: 6px; border-top: 2px solid var(--border); @@ -549,8 +638,8 @@ button:active { .spindle { display: grid; align-content: start; - gap: 2px; - padding: 5px; + gap: 1px; + padding: 5px 6px; background: #f8f5ef; border: 1px solid var(--border); min-width: 0; @@ -563,7 +652,7 @@ button:active { .cooling h2, .spindle h2 { margin: 0; - font-size: 14px; + font-size: 12px; line-height: 1.15; text-align: center; } @@ -573,7 +662,7 @@ button:active { .spindle strong { display: block; overflow: hidden; - font-size: clamp(18px, 1.8vw, 25px); + font-size: clamp(16px, 1.45vw, 22px); line-height: 1.05; text-overflow: ellipsis; white-space: nowrap; @@ -591,20 +680,20 @@ button:active { .meter-card strong { display: inline; - font-size: clamp(20px, 2vw, 25px); + font-size: clamp(18px, 1.6vw, 24px); line-height: 1; } .meter-card span { padding-left: 5px; - font-size: 14px; + font-size: 12px; line-height: 1.1; white-space: nowrap; } .stepper { display: grid; - grid-template-columns: 32px minmax(54px, 1fr) 32px; + grid-template-columns: 30px minmax(54px, 1fr) 30px; gap: 2px; align-items: center; margin-top: 1px; @@ -612,7 +701,7 @@ button:active { .stepper div { min-width: 0; - padding: 6px 4px; + padding: 5px 4px; background: var(--orange); border: 1px solid #b4651c; text-align: center; @@ -623,7 +712,7 @@ button:active { .stepper button { min-width: 0; - min-height: 28px; + min-height: 26px; padding: 2px 4px; } @@ -650,8 +739,8 @@ button:active { } .spindle-range { - height: 24px; - margin-top: 8px; + height: 18px; + margin-top: 4px; background: #bdbdbd; border: 1px solid #898989; } @@ -665,7 +754,7 @@ button:active { .bottom-controls { grid-area: bottom; display: grid; - grid-template-columns: repeat(13, minmax(48px, 1fr)); + grid-template-columns: repeat(15, minmax(48px, 1fr)); gap: 5px; padding: 6px 8px; border-top: 2px solid var(--border); @@ -674,8 +763,11 @@ button:active { } .bottom-controls button { - font-size: 12px; + font-size: clamp(10px, 0.72vw, 12px); + line-height: 1.05; min-width: 0; + padding: 3px 4px; + white-space: normal; } .program-file-input { @@ -684,7 +776,11 @@ button:active { @media (max-width: 1180px) { .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 { diff --git a/web-rtcp-5axis-sim-plan/app/src/ui/gmoccapy-shell.js b/web-rtcp-5axis-sim-plan/app/src/ui/gmoccapy-shell.js index 4555bb5..5eb24db 100644 --- a/web-rtcp-5axis-sim-plan/app/src/ui/gmoccapy-shell.js +++ b/web-rtcp-5axis-sim-plan/app/src/ui/gmoccapy-shell.js @@ -32,6 +32,9 @@ export function mountGmoccapyShell(root, store) { ); store.subscribe((state) => render(regions, state, store.dispatch)); + root.addEventListener("profile-change", (event) => { + store.dispatch({ type: "SET_PROFILE", profileId: event.detail.profileId }); + }); return { getRegions() { @@ -46,7 +49,7 @@ function render(regions, state, dispatch) { renderTitlebar(regions.titlebar, state); renderPreview(regions.preview, state, dispatch); renderDro(regions.dro, state); - renderGcode(regions.gcode, state); + renderGcode(regions.gcode, state, dispatch); renderSidebar(regions["status-sidebar"], state, dispatch); renderInfoTabs(regions["info-tabs"], state); renderOverride(regions.override, state, dispatch); @@ -61,8 +64,22 @@ function renderTitlebar(element, state) { gmoccapy Web 5 Axis for LinuxCNC RTCP Simulation ${state.machineProfile} | ${state.sessionName} | ${state.sourceMode} | ${state.machine.mode} +
${state.runState}
`; + 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) { @@ -137,7 +154,7 @@ function droRow(axis, value, dtg) { `; } -function renderGcode(element, state) { +function renderGcode(element, state, dispatch) { const rows = state.programLines .map((line, index) => { const lineNumber = state.programStartLine + index; @@ -158,12 +175,101 @@ function renderGcode(element, state) { ${state.programSource} Current line ${state.activeLine} +
+ + + ${formatLinuxCncGcodeSourceStatus(state)} +
    ${rows}
${state.activeLine} / ${programEndLine}
+
+
+ MDI + + +
+
+ ${mdiQuickCommands(state).map((command) => ` + + `).join("")} +
+
`; + + 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 ``; + } + const selected = state.machineFileStaging.selectedGcodeSourceRel || ""; + return [ + ``, + ...sources.map((source) => ` + + `), + ].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) { @@ -173,10 +279,10 @@ function renderSidebar(element, state, dispatch) { - + - + `; @@ -191,12 +297,14 @@ function renderSidebar(element, state, dispatch) { dispatch({ type: "SET_KINS_TYPE", kinsType: "identity" }); }); 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) { const frame = state.rtcpFrame; + const taskPolicy = state.linuxCncTaskPolicy; element.innerHTML = `