diff --git a/docs/src/gcode/g-code.adoc b/docs/src/gcode/g-code.adoc index 6253e57afb2..6ae25c5bd9f 100644 --- a/docs/src/gcode/g-code.adoc +++ b/docs/src/gcode/g-code.adoc @@ -1041,10 +1041,11 @@ It is an error if : [source,ngc] ---- -G33 X- Y- Z- K- $- +G33 X- Y- Z- K- $- D- ---- * 'K' - distance per revolution +* 'D' - (optional) start angle offset from the spindle index pulse, in degrees For spindle-synchronized motion in one direction, code 'G33 X- Y- Z- K-' where K gives the distance moved in XYZ for each revolution of the @@ -1064,6 +1065,20 @@ speed pins, so multiple passes line up. 'G33' moves end at the programmed endpoint. G33 could be used to cut tapered threads or a fusee. +The (optional) 'D' argument delays the start of the motion until the spindle +has turned 'D' degrees past the index pulse, instead of starting at the index +pulse itself. If 'D' is omitted the default is zero, which starts at the index +pulse as before. This is how successive starts of a multi-start thread are cut: +the same thread program is run once per start, each with a different 'D'. +For example, a two-start thread is cut with 'D0' and then 'D180'; a three-start +thread with 'D0', 'D120' and 'D240'. + +[NOTE] +'D' is an angle past the index pulse and so is only meaningful as a positive +number. A negative value is not an error: its magnitude is used, so 'D-90' +behaves exactly as 'D90'. Values of 360 or more are not reduced, so 'D450' +waits a full turn longer than 'D90'. + All the axis words are optional, except that at least one must be used. [NOTE] @@ -1082,6 +1097,10 @@ angle. That means that Z will reach the correct position just as it finishes accelerating to the proper speed, and can immediately begin cutting a good thread. +When a 'D' offset is programmed the axis is instead held at rest until the +spindle reaches the requested angle, and tracking begins from there. Allow +enough clearance before the thread for the axis to accelerate from that point. + .HAL Connections The pin 'spindle.N.at-speed' must be set or driven true for the motion to start. Additionally spindle.N.revs must increase by 1 for each revolution @@ -1960,7 +1979,7 @@ In example S100 with 1.25MM per revolution thread pitch gives a feed of F125. [source,ngc] ---- -G76 P- Z- I- J- R- K- Q- H- E- L- $- +G76 P- Z- I- J- R- K- Q- H- E- L- $- D- ---- .G76 Threading @@ -2001,6 +2020,20 @@ Unnecessarily high degression values will produce an unnecessarily high number o * 'H-' - The number of 'spring passes'. Spring passes are additional passes at full thread depth. If no additional passes are desired, program 'H0'. +* 'D-' - The 'start angle offset' in degrees from the spindle index pulse. + Every pass of the cycle begins once the spindle has turned 'D' degrees past + the index pulse, instead of at the index pulse itself. + If 'D' is omitted the default is zero, which starts every pass at the index + pulse as before. + This cuts one start of a multi-start thread: run the same 'G76' cycle once per + start, each with a different 'D'. A two-start thread is cut with 'D0' and then + 'D180', a three-start thread with 'D0', 'D120' and 'D240'. + +[NOTE] +'D' is an angle past the index pulse and so is only meaningful as a positive +number. A negative value is not an error: its magnitude is used, so 'D-90' +behaves exactly as 'D90'. Values of 360 or more are not reduced, so 'D450' +waits a full turn longer than 'D90'. Thread entries and exits can be programmed tapered with the 'E' and 'L' values. diff --git a/docs/src/gcode/overview.adoc b/docs/src/gcode/overview.adoc index 683f72cc154..7b31ab77581 100644 --- a/docs/src/gcode/overview.adoc +++ b/docs/src/gcode/overview.adoc @@ -116,7 +116,8 @@ The table includes N and O for completeness, even though, as defined above, line |A | A axis of machine |B | B axis of machine |C | C axis of machine -|D | Tool radius compensation number +.2+|D | Tool radius compensation number. +<| Start angle offset from the spindle index pulse, in degrees, for G33 and G76 threading. |F | Feed rate |G | General function (See table <>) |H | Tool length offset index diff --git a/src/emc/motion/command.c b/src/emc/motion/command.c index 8905ad05d13..2fe135e21aa 100644 --- a/src/emc/motion/command.c +++ b/src/emc/motion/command.c @@ -1051,7 +1051,7 @@ void emcmotCommandHandler_locked(void *arg, long servo_period) break; case EMCMOT_SET_SPINDLESYNC: - tpSetSpindleSync(&emcmotInternal->coord_tp, emcmotCommand->spindle, emcmotCommand->spindlesync, emcmotCommand->flags); + tpSetSpindleSync(&emcmotInternal->coord_tp, emcmotCommand->spindle, emcmotCommand->spindlesync, emcmotCommand->flags, emcmotCommand->angular_offset_degrees); break; case EMCMOT_SET_LINE: diff --git a/src/emc/motion/motion.h b/src/emc/motion/motion.h index 1312b5e45dd..59ad0672b16 100644 --- a/src/emc/motion/motion.h +++ b/src/emc/motion/motion.h @@ -219,6 +219,7 @@ extern "C" { constraints (the INI file) */ int motion_type; /* this move is because of traverse, feed, arc, or toolchange */ double spindlesync; /* user units per spindle revolution, 0 = no sync */ + double angular_offset_degrees; /* spindle angle offset for threading start (D word) */ double acc; /* max acceleration */ double jerk; /* jerk for traj */ double ini_maxjerk; diff --git a/src/emc/nml_intf/canon.hh b/src/emc/nml_intf/canon.hh index 916b3e92971..2925cd17fca 100644 --- a/src/emc/nml_intf/canon.hh +++ b/src/emc/nml_intf/canon.hh @@ -432,7 +432,7 @@ extern void STOP_CUTTER_RADIUS_COMPENSATION(); translation commands. */ /* used for threading */ -extern void START_SPEED_FEED_SYNCH(int spindle, double feed_per_revolution, bool velocity_mode); +extern void START_SPEED_FEED_SYNCH(int spindle, double feed_per_revolution, bool velocity_mode, double angle_degrees = 0.0); extern void STOP_SPEED_FEED_SYNCH(); diff --git a/src/emc/nml_intf/emc.cc b/src/emc/nml_intf/emc.cc index 3eb7360bdd3..e8a7542f4e3 100644 --- a/src/emc/nml_intf/emc.cc +++ b/src/emc/nml_intf/emc.cc @@ -1115,6 +1115,7 @@ void EMC_TRAJ_SET_SPINDLESYNC::update(CMS * cms) EMC_TRAJ_CMD_MSG::update(cms); cms->update(feed_per_revolution); cms->update(velocity_mode); + cms->update(angular_offset_degrees); } /* diff --git a/src/emc/nml_intf/emc.hh b/src/emc/nml_intf/emc.hh index 2738b34144b..9e5b60200ec 100644 --- a/src/emc/nml_intf/emc.hh +++ b/src/emc/nml_intf/emc.hh @@ -374,7 +374,7 @@ extern int emcTrajLinearMove(const EmcPose& end, int type, double vel, extern int emcTrajCircularMove(const EmcPose& end, const PM_CARTESIAN& center, const PM_CARTESIAN& normal, int turn, int type, double vel, double ini_maxvel, double acc, double ini_maxjerk); extern int emcTrajSetTermCond(int cond, double tolerance); -extern int emcTrajSetSpindleSync(int spindle, double feed_per_revolution, bool wait_for_index); +extern int emcTrajSetSpindleSync(int spindle, double feed_per_revolution, bool wait_for_index, double angular_offset_degrees = 0.0); extern int emcTrajSetOffset(const EmcPose& tool_offset); extern int emcTrajSetHome(const EmcPose& home); extern int emcTrajClearProbeTrippedFlag(); diff --git a/src/emc/nml_intf/emc_nml.hh b/src/emc/nml_intf/emc_nml.hh index 5cede52b09f..1aa377e2562 100644 --- a/src/emc/nml_intf/emc_nml.hh +++ b/src/emc/nml_intf/emc_nml.hh @@ -815,7 +815,8 @@ class EMC_TRAJ_SET_SPINDLESYNC:public EMC_TRAJ_CMD_MSG { : EMC_TRAJ_CMD_MSG(EMC_TRAJ_SET_SPINDLESYNC_TYPE, sizeof(EMC_TRAJ_SET_SPINDLESYNC)), spindle(0), feed_per_revolution(0.0), - velocity_mode(false) + velocity_mode(false), + angular_offset_degrees(0.0) {}; // Sub-class update() calls base-class update() @@ -825,6 +826,7 @@ class EMC_TRAJ_SET_SPINDLESYNC:public EMC_TRAJ_CMD_MSG { int spindle; double feed_per_revolution; bool velocity_mode; + double angular_offset_degrees; // spindle angle offset for threading start (D word, degrees) }; class EMC_TRAJ_SET_OFFSET:public EMC_TRAJ_CMD_MSG { diff --git a/src/emc/rs274ngc/canonmodule.cc b/src/emc/rs274ngc/canonmodule.cc index 3399fb2fc91..bc6417bb4af 100644 --- a/src/emc/rs274ngc/canonmodule.cc +++ b/src/emc/rs274ngc/canonmodule.cc @@ -18,6 +18,7 @@ */ #define BOOST_PYTHON_MAX_ARITY 13 #include +#include #include #include #include @@ -226,7 +227,12 @@ BOOST_PYTHON_MODULE(emccanon) { def("SPINDLE_RETRACT",&SPINDLE_RETRACT); def("SPINDLE_RETRACT_TRAVERSE",&SPINDLE_RETRACT_TRAVERSE); def("START_CUTTER_RADIUS_COMPENSATION",&START_CUTTER_RADIUS_COMPENSATION); - def("START_SPEED_FEED_SYNCH",&START_SPEED_FEED_SYNCH); + // spell the arguments out so the C++ default for angle_degrees survives into + // Python: a bare function pointer would make the new argument mandatory and + // break every existing three-argument caller + def("START_SPEED_FEED_SYNCH",&START_SPEED_FEED_SYNCH, + (arg("spindle"), arg("feed_per_revolution"), arg("velocity_mode"), + arg("angle_degrees")=0.0)); def("START_SPINDLE_CLOCKWISE",&START_SPINDLE_CLOCKWISE); def("START_SPINDLE_COUNTERCLOCKWISE",&START_SPINDLE_COUNTERCLOCKWISE); def("STOP_CUTTER_RADIUS_COMPENSATION",&STOP_CUTTER_RADIUS_COMPENSATION); diff --git a/src/emc/rs274ngc/gcodemodule.cc b/src/emc/rs274ngc/gcodemodule.cc index 3b15edea612..d6ab6424812 100644 --- a/src/emc/rs274ngc/gcodemodule.cc +++ b/src/emc/rs274ngc/gcodemodule.cc @@ -869,7 +869,7 @@ void SET_CUTTER_RADIUS_COMPENSATION(double /*radius*/) {} void START_CUTTER_RADIUS_COMPENSATION(int /*direction*/) {} void STOP_CUTTER_RADIUS_COMPENSATION(int /*direction*/) {} void START_SPEED_FEED_SYNCH() {} -void START_SPEED_FEED_SYNCH(int /*spindle*/, double /*sync*/, bool /*vel*/) {} +void START_SPEED_FEED_SYNCH(int /*spindle*/, double /*sync*/, bool /*vel*/, double /*angle*/) {} void STOP_SPEED_FEED_SYNCH() {} void START_SPINDLE_COUNTERCLOCKWISE(int /*spindle*/, int /*wait_for_at_speed*/) {} void START_SPINDLE_CLOCKWISE(int /*spindle*/, int /*wait_for_at_speed*/) {} diff --git a/src/emc/rs274ngc/interp_check.cc b/src/emc/rs274ngc/interp_check.cc index 196f2772763..07a625d55e3 100644 --- a/src/emc/rs274ngc/interp_check.cc +++ b/src/emc/rs274ngc/interp_check.cc @@ -257,8 +257,9 @@ int Interp::check_other_codes(block_pointer block) //!< pointer to a block (motion != G_70) && (motion != G_71) && (motion != G_71_1) && (motion != G_71_2) && (motion != G_72) && (motion != G_72_1) && (motion != G_72_2) && (motion != G_73) && (motion != G_83) && - (block->g_modes[14] != G_96)), - _("D word with no G41, G41.1, G42, G42.1, G71, G71.1, G71.2 G73, G83 or G96 to use it")); + (block->g_modes[14] != G_96) && + (motion != G_33) && (motion != G_76)), + _("D word with no G41, G41.1, G42, G42.1, G71, G71.1, G71.2 G73, G83, G96, G33 or G76 to use it")); } if (block->dollar_flag) { diff --git a/src/emc/rs274ngc/interp_convert.cc b/src/emc/rs274ngc/interp_convert.cc index 8156c90515f..049f6f73e02 100644 --- a/src/emc/rs274ngc/interp_convert.cc +++ b/src/emc/rs274ngc/interp_convert.cc @@ -5528,7 +5528,10 @@ int Interp::convert_straight(int move, //!< either G_0 or G_1 CHKS(((settings->spindle_turning[settings->active_spindle] != CANON_CLOCKWISE) && (settings->spindle_turning[settings->active_spindle] != CANON_COUNTERCLOCKWISE)), _("Spindle not turning in G33")); - START_SPEED_FEED_SYNCH(settings->active_spindle, block->k_number, 0); + // the offset is a direction-less angle past the index pulse, so a negative + // D is taken as its magnitude rather than rejected + double g33_angle = block->d_flag ? fabs(block->d_number_float) : 0.0; + START_SPEED_FEED_SYNCH(settings->active_spindle, block->k_number, 0, g33_angle); STRAIGHT_FEED(block->line_number, end_x, end_y, end_z, AA_end, BB_end, CC_end, u_end, v_end, w_end); STOP_SPEED_FEED_SYNCH(); settings->current_x = end_x; @@ -5660,26 +5663,27 @@ threading_pass(setup_pointer settings, block_pointer block, int boring, double safe_x, double depth, double end_depth, double start_y, double start_z, double zoff, double taper_dist, int entry_taper, int exit_taper, double taper_pitch, - double pitch, double full_threadheight, double target_z) { + double pitch, double full_threadheight, double target_z, + double angle_offset) { STRAIGHT_TRAVERSE(block->line_number, boring? safe_x + depth - end_depth: safe_x - depth + end_depth, start_y, start_z - zoff, AABBCC); //back if(taper_dist && entry_taper) { DISABLE_FEED_OVERRIDE(); - START_SPEED_FEED_SYNCH(settings->active_spindle, taper_pitch, 0); + START_SPEED_FEED_SYNCH(settings->active_spindle, taper_pitch, 0, angle_offset); STRAIGHT_FEED(block->line_number, boring? safe_x + depth - full_threadheight: safe_x - depth + full_threadheight, start_y, start_z - zoff, AABBCC); //in STRAIGHT_FEED(block->line_number, boring? safe_x + depth: safe_x - depth, //angled in start_y, start_z - zoff - taper_dist, AABBCC); - START_SPEED_FEED_SYNCH(settings->active_spindle, pitch, 0); + START_SPEED_FEED_SYNCH(settings->active_spindle, pitch, 0, angle_offset); } else { STRAIGHT_TRAVERSE(block->line_number, boring? safe_x + depth: safe_x - depth, start_y, start_z - zoff, AABBCC); //in DISABLE_FEED_OVERRIDE(); - START_SPEED_FEED_SYNCH(settings->active_spindle, pitch, 0); + START_SPEED_FEED_SYNCH(settings->active_spindle, pitch, 0, angle_offset); } if(taper_dist && exit_taper) { @@ -5768,6 +5772,9 @@ int Interp::convert_threading_cycle(block_pointer block, int entry_taper = taper_flags & 1; int exit_taper = taper_flags & 2; + // as in G33: a negative D is taken as its magnitude + double angle_offset = block->d_flag ? fabs(block->d_number_float) : 0.0; + double depth, zoff; int pass = 1; @@ -5778,7 +5785,7 @@ int Interp::convert_threading_cycle(block_pointer block, while (depth < end_depth) { threading_pass(settings, block, boring, safe_x, depth, end_depth, start_y, start_z, zoff, taper_dist, entry_taper, exit_taper, - taper_pitch, pitch, full_threadheight, target_z); + taper_pitch, pitch, full_threadheight, target_z, angle_offset); depth = full_dia_depth + cut_increment * pow(++pass, 1.0/degression); zoff = (depth - full_dia_depth) * tan(compound_angle); } @@ -5789,7 +5796,7 @@ int Interp::convert_threading_cycle(block_pointer block, for(int i = 0; iline_number, end_x, end_y, end_z, AABBCC); settings->current_x = end_x; diff --git a/src/emc/sai/saicanon.cc b/src/emc/sai/saicanon.cc index 169e73a8a39..c1254b7ac3a 100644 --- a/src/emc/sai/saicanon.cc +++ b/src/emc/sai/saicanon.cc @@ -737,8 +737,15 @@ int GET_EXTERNAL_AXIS_MASK() {return 0x3f;} // XYZABC machine double GET_EXTERNAL_ANGLE_UNITS() {return 1.0;} int GET_EXTERNAL_SELECTED_TOOL_SLOT() { return 0; } int GET_EXTERNAL_SPINDLE_OVERRIDE_ENABLE(int /*spindle*/) {return so_enable;} -void START_SPEED_FEED_SYNCH(int /*spindle*/, double sync, bool vel) -{PRINT("START_SPEED_FEED_SYNC(%f,%d)\n", sync, vel);} +void START_SPEED_FEED_SYNCH(int /*spindle*/, double sync, bool vel, double angle_degrees) +{ + // only print the angle when one was asked for, so that the expected output + // of tests predating the D word stays valid + if (angle_degrees != 0.0) + PRINT("START_SPEED_FEED_SYNC(%f,%d,%f)\n", sync, vel, angle_degrees); + else + PRINT("START_SPEED_FEED_SYNC(%f,%d)\n", sync, vel); +} CANON_MOTION_MODE motion_mode; int GET_EXTERNAL_DIGITAL_INPUT(int /*index*/, int def) { return def; } diff --git a/src/emc/task/emccanon.cc b/src/emc/task/emccanon.cc index a5f45837c99..2365c4470a8 100644 --- a/src/emc/task/emccanon.cc +++ b/src/emc/task/emccanon.cc @@ -1503,13 +1503,14 @@ void STOP_CUTTER_RADIUS_COMPENSATION() -void START_SPEED_FEED_SYNCH(int spindle, double feed_per_revolution, bool velocity_mode) +void START_SPEED_FEED_SYNCH(int spindle, double feed_per_revolution, bool velocity_mode, double angle_degrees) { flush_segments(); auto spindleSyncMsg = std::make_unique(); spindleSyncMsg->spindle = spindle; spindleSyncMsg->feed_per_revolution = TO_EXT_LEN(FROM_PROG_LEN(feed_per_revolution)); spindleSyncMsg->velocity_mode = velocity_mode; + spindleSyncMsg->angular_offset_degrees = angle_degrees; interp_list.append(std::move(spindleSyncMsg)); canon.spindle[spindle].synched = 1; } diff --git a/src/emc/task/emctaskmain.cc b/src/emc/task/emctaskmain.cc index ff0978fe922..76c7adbb4e8 100644 --- a/src/emc/task/emctaskmain.cc +++ b/src/emc/task/emctaskmain.cc @@ -1887,7 +1887,7 @@ static int emcTaskIssueCommand(NMLmsg * cmd) case EMC_TRAJ_SET_SPINDLESYNC_TYPE: emcTrajSetSpindlesyncMsg = reinterpret_cast(cmd); - retval = emcTrajSetSpindleSync(emcTrajSetSpindlesyncMsg->spindle, emcTrajSetSpindlesyncMsg->feed_per_revolution, emcTrajSetSpindlesyncMsg->velocity_mode); + retval = emcTrajSetSpindleSync(emcTrajSetSpindlesyncMsg->spindle, emcTrajSetSpindlesyncMsg->feed_per_revolution, emcTrajSetSpindlesyncMsg->velocity_mode, emcTrajSetSpindlesyncMsg->angular_offset_degrees); break; case EMC_TRAJ_SET_OFFSET_TYPE: diff --git a/src/emc/task/taskintf.cc b/src/emc/task/taskintf.cc index 482d8bf8afe..4eac12ffee9 100644 --- a/src/emc/task/taskintf.cc +++ b/src/emc/task/taskintf.cc @@ -1505,12 +1505,13 @@ int emcTrajSetOffset(const EmcPose& tool_offset) return usrmotWriteEmcmotCommand(&emcmotCommand); } -int emcTrajSetSpindleSync(int spindle, double fpr, bool wait_for_index) +int emcTrajSetSpindleSync(int spindle, double fpr, bool wait_for_index, double angular_offset_degrees) { emcmotCommand.command = EMCMOT_SET_SPINDLESYNC; emcmotCommand.spindle = spindle; emcmotCommand.spindlesync = fpr; emcmotCommand.flags = wait_for_index; + emcmotCommand.angular_offset_degrees = angular_offset_degrees; return usrmotWriteEmcmotCommand(&emcmotCommand); } diff --git a/src/emc/tp/tc.c b/src/emc/tp/tc.c index e1c9fe5ffe3..60e5b5c5f09 100644 --- a/src/emc/tp/tc.c +++ b/src/emc/tp/tc.c @@ -792,6 +792,7 @@ int tcSetupState(TC_STRUCT * const tc, TP_STRUCT const * const tp) tc->tolerance = tp->tolerance; tc->synchronized = tp->synchronized; tc->uu_per_rev = tp->uu_per_rev; + tc->angle_offset = tp->spindle.pending_offset; return TP_ERR_OK; } diff --git a/src/emc/tp/tc_types.h b/src/emc/tp/tc_types.h index 135b34586ae..b0b3eb40313 100644 --- a/src/emc/tp/tc_types.h +++ b/src/emc/tp/tc_types.h @@ -176,6 +176,7 @@ typedef struct { // stay within this distance from the path. int synchronized; // spindle sync state double uu_per_rev; // for sync, user units per rev (e.g. 0.0625 for 16tpi) + double angle_offset; // spindle angle offset from index (revolutions) for D word double vel_at_blend_start; int sync_accel; // we're accelerating up to sync with the spindle unsigned char enables; // Feed scale, etc, enable bits for this move diff --git a/src/emc/tp/tp.c b/src/emc/tp/tp.c index 7e63d467aba..41814556e60 100644 --- a/src/emc/tp/tp.c +++ b/src/emc/tp/tp.c @@ -500,6 +500,8 @@ int tpInit(TP_STRUCT * const tp) tp->spindle.revs = 0.0; tp->spindle.waiting_for_index = MOTION_INVALID_ID; tp->spindle.waiting_for_atspeed = MOTION_INVALID_ID; + tp->spindle.pending_offset = 0.0; + tp->spindle.angle_hold_pending = 0; tp->reverse_run = TC_DIR_FORWARD; tp->termCond = TC_TERM_COND_PARABOLIC; @@ -3476,8 +3478,18 @@ STATIC tp_err_t tpCheckAtSpeed(TP_STRUCT * const tp, TC_STRUCT * const tc) /* passed index, start the move */ emcmotStatus->spindleSync = 1; tp->spindle.waiting_for_index = MOTION_INVALID_ID; - tc->sync_accel = 1; tp->spindle.revs = 0; + tp->spindle.offset = 0.0; + /* gate on > 0.0, matching tpSyncPositionMode(): a value that does + * not request a hold there must not suppress the ramp here, or the + * move would start with neither */ + if (!(tc->angle_offset > 0.0)) { + /* no angle offset: use sync_accel to ramp up to spindle speed */ + tc->sync_accel = 1; + } + /* if angle_offset > 0: tpSyncPositionMode() will hold Z at rest + * until the spindle reaches angle_offset revolutions past the + * index pulse, then release to tracking mode */ } } return TP_ERR_OK; @@ -3572,6 +3584,9 @@ STATIC tp_err_t tpActivateSegment(TP_STRUCT * const tp, TC_STRUCT * const tc) { // ask for an index reset emcmotStatus->spindle_status[tp->spindle.spindle_num].spindle_index_enable = 1; tp->spindle.offset = 0.0; + // a fresh index sync: any angle offset on the segment that follows it + // has yet to be waited out + tp->spindle.angle_hold_pending = 1; rtapi_print_msg(RTAPI_MSG_DBG, "Waiting on sync. spindle_num %d..\n", tp->spindle.spindle_num); return TP_ERR_WAITING; } @@ -3624,6 +3639,25 @@ STATIC void tpSyncPositionMode(TP_STRUCT * const tp, TC_STRUCT * const tc, tp->spindle.revs = spindle_pos; } + /* Angle-offset hold: after index, keep the axis at rest until the spindle + * has advanced angle_offset revolutions (spindle.revs resets to 0 at the + * index pulse). angle_hold_pending scopes this to the first segment after + * the index -- the later segments of a threading pass carry the same + * angle_offset, and must keep tracking against the offset accumulated by + * tpCompleteSegment() rather than re-zeroing it here. */ + if (tp->spindle.angle_hold_pending && tc->angle_offset > 0.0) { + if (tp->spindle.revs < tc->angle_offset) { + tc->target_vel = 0.0; + return; + } + /* Spindle reached target angle: set offset so pos_desired = 0 now, + * then fall through to normal tracking (sync_accel stays 0). The TC's + * angle_offset is left intact so the segment still describes what was + * programmed, which a reverse run over it depends on. */ + tp->spindle.offset = tp->spindle.revs; + tp->spindle.angle_hold_pending = 0; + } + double pos_desired = (tp->spindle.revs - tp->spindle.offset) * tc->uu_per_rev; double pos_error = pos_desired - tc->progress; @@ -4234,7 +4268,7 @@ int tpRunCycle(TP_STRUCT * const tp, long period) return TP_ERR_OK; } -int tpSetSpindleSync(TP_STRUCT * const tp, int spindle, double sync, int mode) { +int tpSetSpindleSync(TP_STRUCT * const tp, int spindle, double sync, int mode, double angular_offset_degrees) { if(sync) { if (mode) { tp->synchronized = TC_SYNC_VELOCITY; @@ -4243,8 +4277,14 @@ int tpSetSpindleSync(TP_STRUCT * const tp, int spindle, double sync, int mode) { } tp->uu_per_rev = sync; tp->spindle.spindle_num = spindle; - } else + /* the offset is a direction-less angle past the index, so take the + * magnitude -- the interpreter does the same with a negative D word, + * and this keeps the guarantee for any other caller of this API */ + tp->spindle.pending_offset = fabs(angular_offset_degrees) / 360.0; + } else { tp->synchronized = 0; + tp->spindle.pending_offset = 0.0; + } return TP_ERR_OK; } diff --git a/src/emc/tp/tp.h b/src/emc/tp/tp.h index e00b457ad23..9ed9baa4942 100644 --- a/src/emc/tp/tp.h +++ b/src/emc/tp/tp.h @@ -67,7 +67,7 @@ int tpIsDone(TP_STRUCT * const tp); int tpQueueDepth(TP_STRUCT * const tp); int tpActiveDepth(TP_STRUCT * const tp); int tpGetMotionType(TP_STRUCT * const tp); -int tpSetSpindleSync(TP_STRUCT * const tp, int spindle, double sync, int wait); +int tpSetSpindleSync(TP_STRUCT * const tp, int spindle, double sync, int wait, double angular_offset_degrees); int tpSetAout(TP_STRUCT * const tp, unsigned char index, double start, double end); int tpSetDout(TP_STRUCT * const tp, int index, unsigned char start, unsigned char end); //gets called to place DIO toggles on the TC queue diff --git a/src/emc/tp/tp_types.h b/src/emc/tp/tp_types.h index 6687ef3a2d1..9421d90540d 100644 --- a/src/emc/tp/tp_types.h +++ b/src/emc/tp/tp_types.h @@ -83,6 +83,14 @@ typedef struct { double revs; int waiting_for_index; int waiting_for_atspeed; + double pending_offset; // requested angle offset in revolutions (set by tpSetSpindleSync, applied at index) + int angle_hold_pending; // an angle offset is still to be waited out for the + // current index sync; armed when the index wait is + // armed, cleared once the angle has been reached. + // Scopes the hold to the first segment after the + // index, so later segments of the same pass keep + // the spindle offset accumulated by + // tpCompleteSegment() instead of resetting it. } tp_spindle_t; /** diff --git a/tests/interp/compile/use-rs274.cc b/tests/interp/compile/use-rs274.cc index 2d388b5fcec..45e75382484 100644 --- a/tests/interp/compile/use-rs274.cc +++ b/tests/interp/compile/use-rs274.cc @@ -86,7 +86,7 @@ void SET_NAIVECAM_TOLERANCE(double tolerance) {} void SET_CUTTER_RADIUS_COMPENSATION(double radius) {} void START_CUTTER_RADIUS_COMPENSATION(int direction) {} void STOP_CUTTER_RADIUS_COMPENSATION() {} -void START_SPEED_FEED_SYNCH(int spindle, double feed_per_revolution, bool velocity_mode) {} +void START_SPEED_FEED_SYNCH(int spindle, double feed_per_revolution, bool velocity_mode, double angle_degrees) {} void STOP_SPEED_FEED_SYNCH() {} void ARC_FEED(int lineno, diff --git a/tests/interp/g76-d-word/README b/tests/interp/g76-d-word/README new file mode 100644 index 00000000000..a7f67ce2b91 --- /dev/null +++ b/tests/interp/g76-d-word/README @@ -0,0 +1,10 @@ +Checks the D word start angle offset for spindle synchronized motion. + +Covers that the angle reaches the canon layer for both G33 and G76, that it is +absent (and so defaults to zero) when no D is programmed, and that a negative D +is passed on as its magnitude rather than being dropped. + +The G76 case uses an entry taper (L1) on purpose: that makes a single threading +pass emit more than one synchronized move, which is the case where the offset +has to be applied only once, at the index pulse, rather than at the start of +every segment of the pass. diff --git a/tests/interp/g76-d-word/d-word.ngc b/tests/interp/g76-d-word/d-word.ngc new file mode 100644 index 00000000000..ffdac8b7d07 --- /dev/null +++ b/tests/interp/g76-d-word/d-word.ngc @@ -0,0 +1,27 @@ +(Checks that the D word start angle offset reaches the canon layer on G33 and) +(G76, that it defaults to zero when absent, and that a negative D is passed on) +(as its magnitude.) + +g20 g18 +s800 m3 +g0 z.2 x.2 + +(G33 with no D: START_SPEED_FEED_SYNC prints without an angle) +g0 x.2 z0 +g33 z-.5 k.05 + +(G33 with D: the angle is carried through) +g0 x.2 z0 +g33 z-.5 k.05 d90 + +(G33 with a negative D: reported as +90, not -90 and not dropped) +g0 x.2 z0 +g33 z-.5 k.05 d-90 + +(G76 with D and an entry taper. The entry taper makes the pass emit more than) +(one synchronized move, so the offset appears on each START_SPEED_FEED_SYNC of) +(the pass except the exit taper's.) +g0 x.2 z0 +g76 p.05 z-.5 i-.05 j.01 k.02 h0 e.02 l1 d120 + +m2 diff --git a/tests/interp/g76-d-word/expected b/tests/interp/g76-d-word/expected new file mode 100644 index 00000000000..745ad161f55 --- /dev/null +++ b/tests/interp/g76-d-word/expected @@ -0,0 +1,7 @@ + N..... START_SPEED_FEED_SYNC(0.050000,0) + N..... START_SPEED_FEED_SYNC(0.050000,0,90.000000) + N..... START_SPEED_FEED_SYNC(0.050000,0,90.000000) + N..... START_SPEED_FEED_SYNC(0.070711,0,120.000000) + N..... START_SPEED_FEED_SYNC(0.050000,0,120.000000) + N..... START_SPEED_FEED_SYNC(0.070711,0,120.000000) + N..... START_SPEED_FEED_SYNC(0.050000,0,120.000000) diff --git a/tests/interp/g76-d-word/test.sh b/tests/interp/g76-d-word/test.sh new file mode 100755 index 00000000000..894a30c4601 --- /dev/null +++ b/tests/interp/g76-d-word/test.sh @@ -0,0 +1,5 @@ +#!/bin/bash +# Only the spindle-sync calls matter here; the surrounding motion is covered by +# the g33 and g76 tests. +rs274 -g d-word.ngc | awk '{$1=""; print}' | grep 'SPEED_FEED_SYNC(' +exit "${PIPESTATUS[0]}" diff --git a/tests/motion/spindle-angle-offset/angle-offset.ngc b/tests/motion/spindle-angle-offset/angle-offset.ngc new file mode 100644 index 00000000000..22e56d4a05b --- /dev/null +++ b/tests/motion/spindle-angle-offset/angle-offset.ngc @@ -0,0 +1,39 @@ +(Spindle synchronized motion with a D word start angle offset.) +(Each G33 below is a separate synchronized move, so each one waits for its own) +(index pulse -- that reset is what D is measured from.) + +g21 g18 g90 g8 +s600 m3 +g4 p2 (let the simulated spindle reach speed) + +(Reference pass: no D, so motion starts at the index pulse.) +g0 x10 z5 +g33 z-10 k2 + +(D180: must start half a turn later than the reference pass.) +g0 x10 z5 +g33 z-10 k2 d180 + +(Negative D: the magnitude is used, so this must match the D180 pass.) +g0 x10 z5 +g33 z-10 k2 d-180 + +(G76 with an entry taper, run twice with identical geometry and speed: once) +(without D and once with. The entry taper makes one pass emit three) +(synchronized moves instead of one, which is the case where the offset must be) +(applied once at the index rather than at the start of every segment.) +(The pair is compared against itself because the angle it costs to detect that) +(the tool has started moving depends on the spindle speed and on which axis) +(leads, so only two otherwise identical cycles can be differenced.) +s600 +g4 p2 + +(reference cycle, no D) +g0 x10 z5 +g76 p2 z-10 i-1 j0.4 k0.6 r1 q29.5 h0 e1 l1 + +(same cycle offset half a turn) +g0 x10 z5 +g76 p2 z-10 i-1 j0.4 k0.6 r1 q29.5 h0 e1 l1 d180 + +m2 diff --git a/tests/motion/spindle-angle-offset/expected b/tests/motion/spindle-angle-offset/expected new file mode 100644 index 00000000000..8c0382aa4aa --- /dev/null +++ b/tests/motion/spindle-angle-offset/expected @@ -0,0 +1 @@ +Completed successfully diff --git a/tests/motion/spindle-angle-offset/sample.hal b/tests/motion/spindle-angle-offset/sample.hal new file mode 100644 index 00000000000..1f658e3db1e --- /dev/null +++ b/tests/motion/spindle-angle-offset/sample.hal @@ -0,0 +1,24 @@ +# HAL file for sampling spindle-synchronized motion against the spindle angle. +# +# The simulated spindle encoder (LIB:sim_spindle_encoder.hal, loaded from the +# ini) provides the two things this test needs: a revolution count in +# spindle.0.revs, and an index pulse that resets it to zero. The D word start +# angle offset is defined against exactly that reset, so sampling revs next to +# the Z position is enough to see whether the axis was held for the right angle. + +loadrt sampler depth=8000 cfg=ssfffb + +# disable sampler before adding the function +setp sampler.0.enable 0 + +addf sampler.0 servo-thread + +net line-number motion.interp.line-number => sampler.0.pin.0 +net motion-type motion.interp.motion-type => sampler.0.pin.1 +# spindle-pos and spindle-index-enable are the nets made by sim_spindle_encoder.hal +net spindle-pos => sampler.0.pin.2 +net Zpos => sampler.0.pin.3 +# X too: a G76 pass with an entry taper begins with a radial feed, so the move +# that actually waits out the offset is an X move, not a Z move +net Xpos => sampler.0.pin.4 +net spindle-index-enable => sampler.0.pin.5 diff --git a/tests/motion/spindle-angle-offset/test-ui.py b/tests/motion/spindle-angle-offset/test-ui.py new file mode 100755 index 00000000000..115039fca48 --- /dev/null +++ b/tests/motion/spindle-angle-offset/test-ui.py @@ -0,0 +1,313 @@ +#!/usr/bin/env python3 +# +# Checks the D word start angle offset for spindle synchronized motion. +# +# The offset is defined against the spindle index pulse, and the simulated +# spindle encoder resets spindle.0.revs to zero at exactly that pulse. So the +# angle the axis actually waited for can be read straight off the samples: find +# the index reset, then find the first sample where Z starts to move, and look +# at how far the spindle had turned in between. +# +# Detecting "Z started moving" costs a small, fixed amount of spindle rotation +# (the axis has to accelerate far enough to clear the threshold), so no single +# measurement is exact. Every check below is therefore a *difference* between +# two passes that carry that same lag, which cancels it. + +import linuxcnc +import linuxcnc_util +import hal + +import time +import sys +import os + +INTERPTIMEOUT = 5 # Max seconds to wait for the interpreter to start +GCODETIMEOUT = 240 # Max seconds for the gcode program to run + +PITCH = 2.0 # K/P word of every synchronized move in the program, mm/rev +ZMOVED = 0.02 # mm of Z travel counted as "the axis has been released" +REVTOL = 0.08 # rev, tolerance on a measured angle difference (~29 deg) + +# sampler field order, see sample.hal +LINE, MTYPE, REVS, ZPOS, XPOS, INDEX = range(6) + +samples = [] +failures = [] + + +def fail(msg): + print("FAIL: {}".format(msg)) + failures.append(msg) + + +def index_resets(data): + """Split the samples at each index pulse. + + sim_spindle clears index-enable at the moment it zeroes its position, so a + falling edge of that pin is the index reset. Returns a list of sample + lists, one per synchronized move that waited for an index. + """ + groups = [] + start = None + for i in range(1, len(data)): + if data[i - 1][INDEX] and not data[i][INDEX]: + if start is not None: + groups.append(data[start:i]) + start = i + if start is not None: + groups.append(data[start:]) + return groups + + +def release_index(group): + """Position in the group at which the tool began to move, or None. + + Either axis counts: a G33 pass is released into a Z move, but a G76 pass + with an entry taper is released into a radial X feed. + """ + z0, x0 = group[0][ZPOS], group[0][XPOS] + for i, s in enumerate(group): + if abs(s[ZPOS] - z0) > ZMOVED or abs(s[XPOS] - x0) > ZMOVED: + return i + return None + + +def release_angle(group): + """Revolutions past the index at which the tool began to move, or None.""" + i = release_index(group) + return None if i is None else group[i][REVS] + + +def cut_samples(group): + """The synchronized cut itself: from release until Z stops advancing. + + A group runs to the next index pulse, so it also contains the rapids that + reposition for the following pass. Cutting it off where Z stops going + negative keeps those out of the pitch check. + """ + i = release_index(group) + if i is None: + return [] + cut = [group[i]] + stalled = 0 + for s in group[i + 1:]: + if s[ZPOS] > cut[-1][ZPOS] + 0.001: # Z turned round: the cut is over + break + if s[ZPOS] > cut[-1][ZPOS] - 1e-9: + # Z has stopped advancing. The cut is over even though Z has not + # moved back yet: the program dwells here, and the line number is + # no help because a dwell keeps reporting the previous motion line. + stalled += 1 + if stalled > 20: + break + else: + stalled = 0 + cut.append(s) + return cut[:len(cut) - stalled] + + +def cut_pitch(group, z1, z2): + """Measured mm per revolution between two Z positions of a cut. + + Sampled well inside the thread so that neither the entry taper nor the + acceleration at either end is included. + """ + def revs_at(zt): + prev = None + for s in group: + if prev and prev[ZPOS] > zt >= s[ZPOS]: + span = prev[ZPOS] - s[ZPOS] + f = (prev[ZPOS] - zt) / span if span else 0.0 + return prev[REVS] + f * (s[REVS] - prev[REVS]) + prev = s + return None + + a, b = revs_at(z1), revs_at(z2) + if a is None or b is None or b <= a: + return None + return (z1 - z2) / (b - a) + + +def pitch_error(group): + """Largest departure from 'Z advances PITCH per revolution' during the cut.""" + cut = cut_samples(group) + if len(cut) < 20: + return None + # drop the acceleration at each end, where Z is not yet tracking the spindle + cut = cut[len(cut) // 4: -(len(cut) // 4)] + if len(cut) < 5: + return None + zr, rr = cut[0][ZPOS], cut[0][REVS] + return max(abs((zr - s[ZPOS]) - PITCH * (s[REVS] - rr)) for s in cut) + + +# +# Command line: -ngc +# +ngcfile = None +for i in range(1, len(sys.argv) - 1): + if "-ngc" == sys.argv[i]: + ngcfile = sys.argv[i + 1] + break + +if not ngcfile: + print("Missing NGC-file; run with: test-ui.py -ngc ngcfile.ngc") + sys.exit(1) + +if not os.path.exists(ngcfile): + print("NGC-file '{}' does not exist".format(ngcfile)) + sys.exit(1) + +# +# Connect to the sampler stream +# +h = hal.component("python-ui") +sampler = hal.stream(h, hal.sampler_base, "ssfffb") +h.ready() + +c = linuxcnc.command() +s = linuxcnc.stat() +e = linuxcnc.error_channel() + +l = linuxcnc_util.LinuxCNC(command=c, status=s, error=e) +c.state(linuxcnc.STATE_ESTOP_RESET) +c.state(linuxcnc.STATE_ON) + +c.home(-1) +c.wait_complete() +l.wait_for_home([1, 1, 1, 0, 0, 0, 0, 0, 0]) + +c.mode(linuxcnc.MODE_AUTO) +c.program_open(ngcfile) + +hal.set_p("sampler.0.enable", "1") +c.auto(linuxcnc.AUTO_RUN, 1) + +start = time.time() +while time.time() - start < INTERPTIMEOUT: + s.poll() + if s.interp_state != linuxcnc.INTERP_IDLE: + break + time.sleep(0.01) +if s.interp_state == linuxcnc.INTERP_IDLE: + print("Timed out starting interpreter") + sys.exit(1) + + +def drain(): + while sampler.readable: + sample = sampler.read() + if sample is None: + print("Error: sampler read None") + sys.exit(1) + samples.append(sample) + + +start = time.time() +s.poll() +while s.interp_state != linuxcnc.INTERP_IDLE and time.time() - start < GCODETIMEOUT: + drain() + time.sleep(0.005) + s.poll() + +if s.interp_state != linuxcnc.INTERP_IDLE: + print("Timed out running the GCode program") + sys.exit(1) + +hal.set_p("sampler.0.enable", "0") +time.sleep(0.05) +drain() + +print("collected {} samples".format(len(samples))) + +# +# Analysis +# +groups = index_resets(samples) +print("{} synchronized moves waited for an index pulse".format(len(groups))) + +# The program is three G33 passes (no D, D180, D-180) followed by two G76 +# cycles of identical geometry, the second one offset. The pass count of a G76 +# cycle depends on its depth degression, so the two cycles are not assumed to be +# any particular length -- only that they are the same length as each other. +if len(groups) < 5 or (len(groups) - 3) % 2: + fail("expected 3 G33 passes and two G76 cycles of equal length, " + "got {} synchronized moves".format(len(groups))) + print("Test Failed") + sys.exit(1) + +angles = [release_angle(g) for g in groups] +for i, a in enumerate(angles): + print("move {}: released {} rev past index".format( + i, "never" if a is None else "{:.4f}".format(a))) + +npasses = (len(groups) - 3) // 2 +ref, d180, dneg180 = angles[0], angles[1], angles[2] +g76ref, g76d180 = angles[3], angles[3 + npasses] + +for i, a in enumerate(angles): + if a is None: + fail("move {} never moved after its index pulse".format(i)) +if failures: + print("Test Failed") + sys.exit(1) + +# D180 holds for half a turn longer than no D at all. +if abs((d180 - ref) - 0.5) > REVTOL: + fail("D180 released {:.4f} rev after the reference pass, expected 0.5" + .format(d180 - ref)) + +# A negative D is used as its magnitude, so D-180 matches D180. This is the +# check that a negative value is neither rejected nor silently dropped: if it +# were dropped this difference would be the full 0.5 rev. +if abs(dneg180 - d180) > REVTOL: + fail("D-180 released {:.4f} rev past index, D180 released {:.4f}; " + "a negative D should be used as its magnitude" + .format(dneg180, d180)) + +# The reference pass has no offset, so it starts at the index pulse. Only the +# detection lag should separate it from zero. +if ref > REVTOL: + fail("pass without a D word released {:.4f} rev past index, expected ~0" + .format(ref)) + +# G76 honours D too: the offset cycle waits half a turn longer than the +# otherwise identical reference cycle. +if abs((g76d180 - g76ref) - 0.5) > REVTOL: + fail("G76 with D180 released {:.4f} rev past index and the reference " + "cycle {:.4f}, a difference of {:.4f}; expected 0.5" + .format(g76d180, g76ref, g76d180 - g76ref)) + +# Whatever the offset did, the cut itself must still advance one pitch per +# revolution -- a hold that released into the wrong tracking state would show +# up here rather than in the angles above. Only the G33 passes are checked: +# a G76 pass cuts its entry taper at a different pitch by design. +for i, g in enumerate(groups[:3]): + err = pitch_error(g) + if err is None: + continue + print("move {}: pitch tracking error {:.4f} mm".format(i, err)) + if err > 0.5: + fail("move {} drifted {:.4f} mm from {} mm/rev".format(i, err, PITCH)) + +# The same for the body of a G76 pass, measured between two Z positions well +# inside the thread so the entry taper is excluded. Both cycles must cut the +# programmed pitch: an offset that resynchronised the spindle reference part way +# through a pass, or one that dropped the axis out of position tracking, would +# show up as a pitch that is not P. +for i, g in ((3, groups[3]), (3 + npasses, groups[3 + npasses])): + measured = cut_pitch(g, -2.0, -8.0) + if measured is None: + fail("move {} never cut through the Z range the pitch is measured over" + .format(i)) + continue + print("move {}: cut {:.4f} mm/rev".format(i, measured)) + if abs(measured - PITCH) > 0.02: + fail("move {} cut {:.4f} mm/rev, expected {}".format(i, measured, PITCH)) + +if failures: + print("Test Failed") + sys.exit(1) + +print("Completed successfully") +sys.exit(0) diff --git a/tests/motion/spindle-angle-offset/test.ini b/tests/motion/spindle-angle-offset/test.ini new file mode 100644 index 00000000000..f1e9ab76e3b --- /dev/null +++ b/tests/motion/spindle-angle-offset/test.ini @@ -0,0 +1,97 @@ +[EMC] +DEBUG = 0 +VERSION = 1.1 + +[DISPLAY] +DISPLAY = ./test-ui.py -ngc ./angle-offset.ngc + +[TASK] +TASK = milltask +CYCLE_TIME = 0.001 + +[RS274NGC] +PARAMETER_FILE = sim.var + +[EMCMOT] +EMCMOT = motmod +BASE_PERIOD = 0 +SERVO_PERIOD = 1000000 + +[HAL] +HALFILE = LIB:core_sim.hal +HALFILE = LIB:sim_spindle_encoder.hal +HALFILE = ./sample.hal + +[TRAJ] +NO_FORCE_HOMING = 1 +AXES = 3 +COORDINATES = X Y Z +HOME = 0 0 0 +LINEAR_UNITS = mm +ANGULAR_UNITS = degree +DEFAULT_LINEAR_VELOCITY = 100 +MAX_LINEAR_VELOCITY = 500 + +[KINS] +KINEMATICS = trivkins +JOINTS = 3 + +[AXIS_X] +MIN_LIMIT = -1000.0 +MAX_LIMIT = 1000.0 +MAX_VELOCITY = 500 +MAX_ACCELERATION = 3000 + +[JOINT_0] +TYPE = LINEAR +HOME = 0.000 +MAX_VELOCITY = 500 +MAX_ACCELERATION = 3000 +BACKLASH = 0.000 +INPUT_SCALE = 4000 +OUTPUT_SCALE = 1.000 +MIN_LIMIT = -1000.0 +MAX_LIMIT = 1000.0 +FERROR = 0.050 +MIN_FERROR = 0.010 +HOME_SEQUENCE = 0 + +[AXIS_Y] +MIN_LIMIT = -1000.0 +MAX_LIMIT = 1000.0 +MAX_VELOCITY = 500 +MAX_ACCELERATION = 3000 + +[JOINT_1] +TYPE = LINEAR +HOME = 0.000 +MAX_VELOCITY = 500 +MAX_ACCELERATION = 3000 +BACKLASH = 0.000 +INPUT_SCALE = 4000 +OUTPUT_SCALE = 1.000 +MIN_LIMIT = -1000.0 +MAX_LIMIT = 1000.0 +FERROR = 0.050 +MIN_FERROR = 0.010 +HOME_SEQUENCE = 0 + +[AXIS_Z] +MIN_LIMIT = -500.0 +MAX_LIMIT = 500.0 +MAX_VELOCITY = 500 +MAX_ACCELERATION = 3000 + +[JOINT_2] +TYPE = LINEAR +HOME = 0.0 +MAX_VELOCITY = 500 +MAX_ACCELERATION = 3000 +BACKLASH = 0.000 +INPUT_SCALE = 4000 +OUTPUT_SCALE = 1.000 +MIN_LIMIT = -500.0 +MAX_LIMIT = 500.0 +FERROR = 0.050 +MIN_FERROR = 0.010 +HOME_SEQUENCE = 0 diff --git a/tests/motion/spindle-angle-offset/test.sh b/tests/motion/spindle-angle-offset/test.sh new file mode 100755 index 00000000000..87a3bca3260 --- /dev/null +++ b/tests/motion/spindle-angle-offset/test.sh @@ -0,0 +1,4 @@ +#!/bin/bash +if linuxcnc -r test.ini; then + echo "Completed successfully" > result +fi