diff --git a/scripts/test_motion_runtime.py b/scripts/test_motion_runtime.py index f1efe7c..b816881 100644 --- a/scripts/test_motion_runtime.py +++ b/scripts/test_motion_runtime.py @@ -22,7 +22,7 @@ class MotionRuntimeTests(unittest.TestCase): if not module.is_file(): self.skipTest("WASM build has not been generated") text = module.read_text(encoding="utf-8") - for symbol in ("sim_motion_init", "sim_motion_enqueue", "sim_motion_step", "sim_motion_state_hash", "sim_motion_canonical"): + for symbol in ("sim_motion_init", "sim_motion_enqueue", "sim_motion_step", "sim_motion_set_feed_override", "sim_motion_state_hash", "sim_motion_canonical"): self.assertIn(symbol, text) def test_report_contract(self): diff --git a/wasm-core/CMakeLists.txt b/wasm-core/CMakeLists.txt index ba4b117..223a76d 100644 --- a/wasm-core/CMakeLists.txt +++ b/wasm-core/CMakeLists.txt @@ -253,7 +253,7 @@ if(EMSCRIPTEN) string(REPLACE "_sim_io_init" "_sim_io_size','_sim_io_init" _sim_module_link_flags "${_sim_module_link_flags}") string(REPLACE "_sim_io_set_estop','_sim_io_recover" "_sim_io_set_estop','_sim_io_set_power','_sim_io_recover" _sim_module_link_flags "${_sim_module_link_flags}") string(REPLACE "_sim_device_init" "_sim_device_io_size','_sim_device_init" _sim_module_link_flags "${_sim_module_link_flags}") - string(REPLACE "_sim_motion_step','_sim_motion_read_state" "_sim_motion_step','_sim_motion_set_profile','_sim_motion_set_lookahead','_sim_motion_peek','_sim_motion_read_state" _sim_module_link_flags "${_sim_module_link_flags}") + string(REPLACE "_sim_motion_step','_sim_motion_read_state" "_sim_motion_step','_sim_motion_set_profile','_sim_motion_set_lookahead','_sim_motion_set_feed_override','_sim_motion_peek','_sim_motion_read_state" _sim_module_link_flags "${_sim_module_link_flags}") string(REPLACE "_sim_motion_state_hash','_sim_motion_canonical" "_sim_motion_state_hash','_sim_motion_plan_hash','_sim_motion_canonical" _sim_module_link_flags "${_sim_module_link_flags}") set_target_properties(sim_core_module PROPERTIES LINK_FLAGS "${_sim_module_link_flags}") endif() diff --git a/wasm-core/include/sim_motion_queue.h b/wasm-core/include/sim_motion_queue.h index 57d9d61..41a9dbf 100644 --- a/wasm-core/include/sim_motion_queue.h +++ b/wasm-core/include/sim_motion_queue.h @@ -30,8 +30,11 @@ typedef struct sim_motion_command { sim_posemath_segment segment; uint32_t id; uint32_t tag; + uint32_t flags; } sim_motion_command; +#define SIM_MOTION_COMMAND_FLAG_RAPID 1u + typedef struct sim_motion_state { sim_posemath_pose position; double velocity; @@ -65,6 +68,7 @@ int sim_motion_step(sim_motion_queue *queue); int sim_motion_set_profile(sim_motion_queue *queue, int profile); int sim_motion_set_lookahead(sim_motion_queue *queue, uint32_t depth); int sim_motion_set_dynamics(sim_motion_queue *queue, double acceleration_limit, double jerk_limit); +int sim_motion_set_feed_override(sim_motion_queue *queue, double feed_override); int sim_motion_set_paused(sim_motion_queue *queue, uint32_t paused); int sim_motion_peek(const sim_motion_queue *queue, uint32_t offset, sim_motion_command *command); int sim_motion_read_state(const sim_motion_queue *queue, sim_motion_state *state); diff --git a/wasm-core/include/sim_task.h b/wasm-core/include/sim_task.h index 1c4d113..735f3e4 100644 --- a/wasm-core/include/sim_task.h +++ b/wasm-core/include/sim_task.h @@ -70,6 +70,7 @@ enum sim_task_gate_command { SIM_TASK_GATE_SET_MODE = 13, SIM_TASK_GATE_PROGRAM_LOAD = 14, SIM_TASK_GATE_STEP = 15, + SIM_TASK_GATE_OVERRIDE = 16, }; enum sim_task_gate_result { diff --git a/wasm-core/src/sim_motion_queue.c b/wasm-core/src/sim_motion_queue.c index 77e123a..518d581 100644 --- a/wasm-core/src/sim_motion_queue.c +++ b/wasm-core/src/sim_motion_queue.c @@ -12,6 +12,7 @@ typedef struct motion_meta { uint32_t lookahead; double acceleration_limit; double jerk_limit; + double feed_override; double current_velocity; double current_acceleration; uint32_t paused; @@ -34,6 +35,7 @@ static motion_meta *meta_for(const sim_motion_queue *queue, int create) { free_slot->lookahead = 1; free_slot->acceleration_limit = 1e9; free_slot->jerk_limit = 1e12; + free_slot->feed_override = 1.0; free_slot->paused = 0; return free_slot; } @@ -106,6 +108,7 @@ int sim_motion_reset(sim_motion_queue *queue) { meta->lookahead = 1; meta->acceleration_limit = 1e9; meta->jerk_limit = 1e12; + meta->feed_override = 1.0; meta->current_velocity = 0.0; meta->current_acceleration = 0.0; meta->paused = 0; @@ -147,6 +150,13 @@ int sim_motion_set_dynamics(sim_motion_queue *queue, double acceleration_limit, return SIM_MOTION_OK; } +int sim_motion_set_feed_override(sim_motion_queue *queue, double feed_override) { + motion_meta *meta = meta_for(queue, 1); + if (meta == NULL || !isfinite(feed_override) || feed_override < 0.0 || feed_override > 2.0) return SIM_MOTION_ERR_RANGE; + meta->feed_override = feed_override; + return SIM_MOTION_OK; +} + int sim_motion_set_paused(sim_motion_queue *queue, uint32_t paused) { motion_meta *meta = meta_for(queue, 1); if (meta == NULL || paused > 1u) return SIM_MOTION_ERR_ARGUMENT; @@ -174,11 +184,12 @@ static double planned_velocity(const sim_motion_queue *queue, uint32_t offset) { uint32_t index; if (queue == NULL || offset >= queue->count) return 0.0; index = (queue->head + offset) % SIM_MOTION_QUEUE_CAPACITY; - velocity = queue->commands[index].segment.velocity; + velocity = queue->commands[index].segment.velocity * (meta == NULL || (queue->commands[index].flags & SIM_MOTION_COMMAND_FLAG_RAPID) != 0u ? 1.0 : meta->feed_override); if (meta == NULL || meta->profile == SIM_MOTION_PROFILE_EXACT_STOP) return velocity; for (index = 1; index <= meta->lookahead && offset + index < queue->count; ++index) { uint32_t next = (queue->head + offset + index) % SIM_MOTION_QUEUE_CAPACITY; - if (queue->commands[next].segment.velocity < velocity) velocity = queue->commands[next].segment.velocity; + const double next_velocity = queue->commands[next].segment.velocity * ((queue->commands[next].flags & SIM_MOTION_COMMAND_FLAG_RAPID) != 0u ? 1.0 : meta->feed_override); + if (next_velocity < velocity) velocity = next_velocity; } return velocity; } diff --git a/wasm-core/src/sim_task.c b/wasm-core/src/sim_task.c index aa1d1d2..d256e20 100644 --- a/wasm-core/src/sim_task.c +++ b/wasm-core/src/sim_task.c @@ -224,6 +224,10 @@ int32_t sim_task_command_gate(const sim_task *task, uint32_t command, /* AXIS allows jog commands while idle in all three task modes; the manual tab changes mode before sending the command when required. */ return machine_on && idle ? SIM_TASK_GATE_ALLOWED : io_estop ? SIM_TASK_GATE_ESTOP_ACTIVE : machine_on ? SIM_TASK_GATE_INTERP_ACTIVE : SIM_TASK_GATE_MACHINE_OFF; + case SIM_TASK_GATE_OVERRIDE: + /* Feed override is an online trajectory control. LinuxCNC accepts it + while AUTO is running or paused, provided the machine is powered. */ + return io_estop ? SIM_TASK_GATE_ESTOP_ACTIVE : machine_on ? SIM_TASK_GATE_ALLOWED : SIM_TASK_GATE_MACHINE_OFF; case SIM_TASK_GATE_MDI: if (io_estop || !machine_on) return io_estop ? SIM_TASK_GATE_ESTOP_ACTIVE : SIM_TASK_GATE_MACHINE_OFF; if (!idle) return SIM_TASK_GATE_INTERP_ACTIVE; diff --git a/wasm-core/tests/sim_motion_queue_test.c b/wasm-core/tests/sim_motion_queue_test.c index 3357e39..6b86862 100644 --- a/wasm-core/tests/sim_motion_queue_test.c +++ b/wasm-core/tests/sim_motion_queue_test.c @@ -44,6 +44,28 @@ int main(void) { assert(sim_motion_set_profile(&queue, SIM_MOTION_PROFILE_EXACT_STOP) == SIM_MOTION_OK); assert(sim_motion_set_lookahead(&queue, 0) == SIM_MOTION_ERR_RANGE); assert(sim_motion_set_dynamics(&queue, 0.0, 1.0) == SIM_MOTION_ERR_RANGE); + { + sim_motion_queue override_queue = {0}; + sim_motion_state override_state = {0}; + sim_motion_command override_command = {0}; + override_command.segment = line(0, 1); + assert(sim_motion_init(&override_queue, 0.1, 10.0) == SIM_MOTION_OK); + assert(sim_motion_set_feed_override(&override_queue, 0.5) == SIM_MOTION_OK); + assert(sim_motion_set_feed_override(&override_queue, 0.5) == SIM_MOTION_OK); + assert(sim_motion_set_feed_override(&override_queue, -0.1) == SIM_MOTION_ERR_RANGE); + assert(sim_motion_set_feed_override(&override_queue, 2.1) == SIM_MOTION_ERR_RANGE); + assert(sim_motion_enqueue(&override_queue, &override_command, 0) == SIM_MOTION_OK); + assert(sim_motion_step(&override_queue) == SIM_MOTION_OK); + assert(sim_motion_read_state(&override_queue, &override_state) == SIM_MOTION_OK); + assert(override_state.position.tran.x > 0.049 && override_state.position.tran.x < 0.051); + assert(override_state.velocity > 0.49 && override_state.velocity < 0.51); + sim_motion_reset(&override_queue); + override_command.flags = SIM_MOTION_COMMAND_FLAG_RAPID; + assert(sim_motion_enqueue(&override_queue, &override_command, 0) == SIM_MOTION_OK); + assert(sim_motion_step(&override_queue) == SIM_MOTION_OK); + assert(sim_motion_read_state(&override_queue, &override_state) == SIM_MOTION_OK); + assert(override_state.position.tran.x > 0.099 && override_state.position.tran.x < 0.101); + } command.segment = line(9, 10); assert(sim_motion_enqueue(&queue, &command, 0) == SIM_MOTION_ERR_STATE); command.segment.motion_type = 99; diff --git a/wasm-core/tests/sim_task_test.c b/wasm-core/tests/sim_task_test.c index ae23a9f..f8f0bde 100644 --- a/wasm-core/tests/sim_task_test.c +++ b/wasm-core/tests/sim_task_test.c @@ -28,6 +28,9 @@ int main(void) { assert(sim_task_command_gate(&task, SIM_TASK_GATE_PAUSE, 0, 1, 0, 1, 0, 10) == SIM_TASK_GATE_PAUSE_STATE); assert(sim_task_set_mode(&task, SIM_TASK_MODE_AUTO) == SIM_TASK_OK); assert(sim_task_command_gate(&task, SIM_TASK_GATE_PAUSE, 0, 1, 0, 1, 0, 10) == SIM_TASK_GATE_ALLOWED); + assert(sim_task_command_gate(&task, SIM_TASK_GATE_OVERRIDE, 0, 1, 0, 1, 0, 10) == SIM_TASK_GATE_ALLOWED); + assert(sim_task_command_gate(&task, SIM_TASK_GATE_OVERRIDE, 0, 0, 0, 1, 0, 10) == SIM_TASK_GATE_MACHINE_OFF); + assert(sim_task_command_gate(&task, SIM_TASK_GATE_OVERRIDE, 0, 1, 1, 1, 0, 10) == SIM_TASK_GATE_ESTOP_ACTIVE); assert(sim_task_command_gate(&task, SIM_TASK_GATE_POWER, 0, 1, 0, 1, 0, 10) == SIM_TASK_GATE_ALLOWED); assert(sim_task_command_gate(&task, SIM_TASK_GATE_SET_MODE, SIM_TASK_MODE_MANUAL, 1, 0, 1, 0, 10) == SIM_TASK_GATE_MODE_AUTO_ACTIVE); assert(sim_task_command_gate(&task, SIM_TASK_GATE_ESTOP, 1, 1, 0, 1, 0, 10) == SIM_TASK_GATE_ALLOWED); diff --git a/web/react/src/App.tsx b/web/react/src/App.tsx index d0ffdf9..90507f1 100644 --- a/web/react/src/App.tsx +++ b/web/react/src/App.tsx @@ -189,9 +189,12 @@ export function App() { const programFileInput = useRef(null); const [plotCleared, setPlotCleared] = useState(false); const [operationTrace, setOperationTrace] = useState([]); + const [feedOverrideDraft, setFeedOverrideDraft] = useState(100); + const feedOverridePending = useRef(null); + const feedOverrideTimer = useRef | null>(null); const [previewProgress, setPreviewProgress] = useState<{ percent: number; phase: "reading" | "wasm" | "ready" | "error"; detail: string } | null>(null); const previewProgressTimer = useRef | null>(null); - useEffect(() => { const unsubscribe = client.subscribe((message) => setState((previous) => reduceFrontendState(previous, message))); client.ensureSession().then(async () => { const bundle = await loadSimulationCase({ path: DEFAULT_CASE, baseUrl: "." }); if (bundle.status !== "ready") throw new Error(`xyzac_case_blocked:${bundle.missing.join(",")}`); const source = bundle.files?.[PROGRAM_PATH]; if (typeof source !== "string" || !source.trim()) throw new Error("xyzac_demo_program_missing"); const normalizedSource = normalizeProgramSource(source); setProgramText(normalizedSource); setProgramPath(PROGRAM_PATH); const configuration = toWorkerConfiguration(bundle); await operationController.execute("LOAD_WORKSPACE", { root_path: "/workspace", configuration }, { state: {}, gate: false }); await operationController.execute("PROGRAM_LOAD", { source: normalizedSource, path: PROGRAM_PATH }, { state: {}, gate: false }); setOperationTrace(operationController.exportTrace()); setCaseStatus(`${DEFAULT_CASE} + ${PROGRAM_PATH} loaded`); }).catch((error: Error) => { setOperationTrace(operationController.exportTrace()); setCommandStatus(`Error: ${error.message}`); setCaseStatus(`blocked: ${error.message}`); }); return () => { unsubscribe(); if (runTimer.current) clearInterval(runTimer.current); if (jogTimer.current) clearInterval(jogTimer.current); if (previewProgressTimer.current) clearInterval(previewProgressTimer.current); client.close(); }; }, [client, operationController]); + useEffect(() => { const unsubscribe = client.subscribe((message) => setState((previous) => reduceFrontendState(previous, message))); client.ensureSession().then(async () => { const bundle = await loadSimulationCase({ path: DEFAULT_CASE, baseUrl: "." }); if (bundle.status !== "ready") throw new Error(`xyzac_case_blocked:${bundle.missing.join(",")}`); const source = bundle.files?.[PROGRAM_PATH]; if (typeof source !== "string" || !source.trim()) throw new Error("xyzac_demo_program_missing"); const normalizedSource = normalizeProgramSource(source); setProgramText(normalizedSource); setProgramPath(PROGRAM_PATH); const configuration = toWorkerConfiguration(bundle); await operationController.execute("LOAD_WORKSPACE", { root_path: "/workspace", configuration }, { state: {}, gate: false }); await operationController.execute("PROGRAM_LOAD", { source: normalizedSource, path: PROGRAM_PATH }, { state: {}, gate: false }); setOperationTrace(operationController.exportTrace()); setCaseStatus(`${DEFAULT_CASE} + ${PROGRAM_PATH} loaded`); }).catch((error: Error) => { setOperationTrace(operationController.exportTrace()); setCommandStatus(`Error: ${error.message}`); setCaseStatus(`blocked: ${error.message}`); }); return () => { unsubscribe(); if (runTimer.current) clearInterval(runTimer.current); if (jogTimer.current) clearInterval(jogTimer.current); if (previewProgressTimer.current) clearInterval(previewProgressTimer.current); if (feedOverrideTimer.current) clearTimeout(feedOverrideTimer.current); client.close(); }; }, [client, operationController]); const axes = state.snapshot?.motion?.axes ?? [0, 0, 0]; const taskRecord = state.task ?? {}; const axisState = { task: { ...taskRecord, state: taskRecord.state ?? "OFF", mode: taskRecord.mode ?? "MANUAL", interp_state: taskRecord.interp_state ?? (state.status === "running" ? "READING" : state.status === "paused" ? "PAUSED" : "IDLE") }, program: state.program ?? {}, motion: state.snapshot?.motion ?? {}, devices: state.devices ?? {}, xyzac: state.snapshot?.xyzac ?? {}, gates: state.gates }; @@ -207,7 +210,7 @@ export function App() { const programPaused = operationState === "PAUSED"; const idle = operationState === "IDLE"; const wasmGate = (action: string) => state.gates[action] ?? { action, enabled: false, reason: "linuxcnc_wasm_snapshot_pending" }; - const gates = { load: wasmGate("load"), run: wasmGate("run"), step: wasmGate("step"), pause: wasmGate("pause"), resume: wasmGate("resume"), abort: wasmGate("abort"), mdi: wasmGate("mdi"), jog: wasmGate("jog"), home: wasmGate("home"), power: wasmGate("power"), reset: wasmGate("reset") }; + const gates = { load: wasmGate("load"), run: wasmGate("run"), step: wasmGate("step"), pause: wasmGate("pause"), resume: wasmGate("resume"), abort: wasmGate("abort"), mdi: wasmGate("mdi"), jog: wasmGate("jog"), home: wasmGate("home"), power: wasmGate("power"), reset: wasmGate("reset"), override: wasmGate("override") }; const planner = state.snapshot?.authoritative?.planner; const trajectory = [...(planner?.queue ?? []), ...(planner?.active ? [planner.active] : [])]; const stopRunTimer = () => { if (runTimer.current) { clearInterval(runTimer.current); runTimer.current = null; } }; @@ -421,6 +424,34 @@ export function App() { const coolant = { mist: Boolean((state.deviceIo as Record | null)?.coolant?.mist_requested), flood: Boolean((state.deviceIo as Record | null)?.coolant?.flood_requested) }; const onSpindle = (direction: number) => { execute("SPINDLE_COMMAND", { spindle: 0, direction, rpm: direction === 0 ? 0 : 600, enabled: direction !== 0 }); }; const onCoolant = (key: "mist" | "flood") => { const next = { ...coolant, [key]: !coolant[key] }; execute("COOLANT_COMMAND", { mist: next.mist, flood: next.flood, lube: false }); }; + const wasmFeedOverride = Number(state.snapshot?.overrides?.feed ?? 1); + const feedOverridePercent = Number.isFinite(wasmFeedOverride) ? Math.max(0, Math.min(200, Math.round(wasmFeedOverride * 100))) : 100; + useEffect(() => { + if (feedOverridePending.current === null) setFeedOverrideDraft(feedOverridePercent); + else if (feedOverridePercent === feedOverridePending.current) { + feedOverridePending.current = null; + setFeedOverrideDraft(feedOverridePercent); + } + }, [feedOverridePercent]); + const requestFeedOverride = (event: React.ChangeEvent) => { + const percent = Math.max(0, Math.min(200, Number(event.target.value))); + if (!Number.isFinite(percent)) return; + setFeedOverrideDraft(percent); + feedOverridePending.current = percent; + if (feedOverrideTimer.current) clearTimeout(feedOverrideTimer.current); + feedOverrideTimer.current = setTimeout(() => { + feedOverrideTimer.current = null; + const target = feedOverridePending.current; + if (target === null) return; + void dispatch("SET_OVERRIDE", { feed: target / 100 }).then(() => { + if (feedOverridePending.current === target) feedOverridePending.current = null; + }).catch(() => { + if (feedOverridePending.current !== target) return; + feedOverridePending.current = null; + setFeedOverrideDraft(feedOverridePercent); + }); + }, 80); + }; const activeGcodes = programLoading ? "" : state.program?.active_gcodes ? String(state.program.active_gcodes) : "G21 G90\nG17 G40 G49\nG54 G64\nM5 M9"; const toolNumber = String((state.devices as Record | null)?.tool?.current ?? state.snapshot?.tool?.current ?? 0); const currentLine = Number(state.program?.line ?? state.task?.current_line ?? 0) || null; @@ -443,7 +474,7 @@ export function App() { { void releaseEstop().catch(() => undefined); }} onPower={powerOn} onHomeAll={homeAll} onRun={runProgram} onSequence={runStartupSequence} />
-
{(["path", "live", "limits", "velocity", "offsets", "extents"] as const).map((layer) => )}
+
{(["path", "live", "limits", "velocity", "offsets", "extents"] as const).map((layer) => )}
execute("PROGRAM_STEP")} canLoad={gates.load.enabled && !programLoading} canRun={gates.run.enabled && !programLoading} canStep={gates.step.enabled && !programLoading} currentLine={visibleCurrentLine} executionLine={visibleExecutionLine} executionLineText={visibleExecutionLineText} diagnostic={state.error?.code ?? null} />
{taskState}Machine {machineState}Operation {operationState}Tool {toolNumber}{position}{state.error ? `Error: ${state.error.code}` : commandStatus}
diff --git a/web/react/src/store.ts b/web/react/src/store.ts index ce1d13d..2a4bd8b 100644 --- a/web/react/src/store.ts +++ b/web/react/src/store.ts @@ -87,6 +87,7 @@ function projection(result: Record, message: MessageEnvelope, p state_hash: String(result.state_hash ?? previous.snapshot.state_hash), task: (result.task as SimSnapshotV1["task"]) ?? previous.snapshot.task, program: (result.program as Record) ?? previous.snapshot.program, + overrides: (result.overrides as SimSnapshotV1["overrides"]) ?? previous.snapshot.overrides, motion: { ...previous.snapshot.motion, // Keep the XYZAC axis vector intact on every motion delta. Older @@ -218,7 +219,7 @@ function projection(result: Record, message: MessageEnvelope, p motion_trace: [], } : previous.snapshot?.visual; - const snapshot = { simulation_tick: message.simulation_tick ?? previous.tick, state_hash: String(result.state_hash ?? previous.snapshot?.state_hash ?? ""), task: result.task as SimSnapshotV1["task"], motion: { axes, joints, velocity: Array.isArray(motionState.velocity) ? motionState.velocity as number[] : [], homed: typeof motionState.homed === "boolean" ? motionState.homed : undefined, homed_axes: Array.isArray(motionState.homed_axes) ? motionState.homed_axes as boolean[] : undefined }, authoritative, ...(visual ? { visual } : {}) } satisfies SimSnapshotV1; + const snapshot = { simulation_tick: message.simulation_tick ?? previous.tick, state_hash: String(result.state_hash ?? previous.snapshot?.state_hash ?? ""), task: result.task as SimSnapshotV1["task"], motion: { axes, joints, velocity: Array.isArray(motionState.velocity) ? motionState.velocity as number[] : [], homed: typeof motionState.homed === "boolean" ? motionState.homed : undefined, homed_axes: Array.isArray(motionState.homed_axes) ? motionState.homed_axes as boolean[] : undefined }, overrides: (result.overrides as SimSnapshotV1["overrides"]) ?? previous.snapshot?.overrides, authoritative, ...(visual ? { visual } : {}) } satisfies SimSnapshotV1; return { snapshot, toolpath: nextToolpath, task: (result.task as Record) ?? previous.task, program: programResult as Record ?? previous.program, devices: (result.devices as Record) ?? previous.devices, deviceIo: (result.device_io as Record) ?? previous.deviceIo, hal: (result.hal_runtime as Record) ?? previous.hal, plc: (result.plc as Record) ?? previous.plc, python: (result.python as Record) ?? previous.python, ioAck: (result.io_ack as Record) ?? previous.ioAck, axisState: (result.axis_state as AxisStateView) ?? previous.axisState, gates: (result.gates as FrontendState["gates"]) ?? previous.gates, executionTrace }; } diff --git a/web/worker/sim-session.mjs b/web/worker/sim-session.mjs index 937017f..0e89081 100644 --- a/web/worker/sim-session.mjs +++ b/web/worker/sim-session.mjs @@ -59,7 +59,7 @@ const PANEL_GATE_ACTIONS = Object.freeze({ const WASM_GATE_COMMANDS = Object.freeze({ ESTOP: 1, RESET: 2, POWER: 3, HOME: 4, JOG: 5, MDI: 6, RUN: 7, PAUSE: 8, RESUME: 9, ABORT: 10, RESTART: 11, KINEMATICS: 12, - SET_MODE: 13, PROGRAM_LOAD: 14, STEP: 15, + SET_MODE: 13, PROGRAM_LOAD: 14, STEP: 15, OVERRIDE: 16, }); const WASM_GATE_REASONS = Object.freeze({ @@ -228,7 +228,7 @@ export class SimSession { axes: { x: position.x, y: position.y, z: position.z }, joints: kinematicsJoints ?? (home.status === "verified" && Array.isArray(home.state.joints) ? [...home.state.joints] : [position.x, position.y, position.z, position.a ?? 0, position.c ?? 0]), velocity: wasmMotion.status === "verified" ? wasmMotion.state.velocity : 0, - feed_override: 1, + feed_override: this.wasmMotion.controls?.feed_override ?? 1, spindle_override: 1, homed: Boolean(home.state?.homed), homed_axes: Array.from({ length: 5 }, (_, index) => Boolean(home.state?.homed && index < (home.state?.joint_count ?? 0))), @@ -319,6 +319,7 @@ export class SimSession { jogStart: this.xyzacCommandGate({ kind: "JOG_START", payload: {} }), jogStop: this.xyzacCommandGate({ kind: "JOG_STOP", payload: {} }), home: this.xyzacCommandGate({ kind: "HOME", payload: {} }), + override: this.xyzacCommandGate({ kind: "SET_OVERRIDE", payload: { feed: 1 } }), estop: this.xyzacCommandGate({ kind: "ESTOP", payload: { active: true } }), recover: this.xyzacCommandGate({ kind: "IO_RECOVER", payload: {} }), power: this.xyzacCommandGate({ kind: "POWER", payload: { active: ioState.estop ? false : !Boolean(ioState.ready) } }), @@ -439,7 +440,8 @@ export class SimSession { const ready = Boolean(io.ready) && !estop; const loaded = program.status !== 0; const action = PANEL_GATE_ACTIONS[input.kind] ?? null; - const command = input.kind === "JOG_STOP" || input.kind === "JOG_START" || input.kind === "SET_OVERRIDE" ? WASM_GATE_COMMANDS.JOG + const command = input.kind === "JOG_STOP" || input.kind === "JOG_START" ? WASM_GATE_COMMANDS.JOG + : input.kind === "SET_OVERRIDE" ? WASM_GATE_COMMANDS.OVERRIDE : input.kind === "PROGRAM_LOAD" ? WASM_GATE_COMMANDS.PROGRAM_LOAD : input.kind === "PROGRAM_RESTART" ? WASM_GATE_COMMANDS.RESTART : input.kind === "PROGRAM_RUN" ? WASM_GATE_COMMANDS.RUN @@ -1813,7 +1815,13 @@ export class SimSession { if (input.kind === "SET_OVERRIDE") { const feed = Number(input.payload?.feed ?? input.payload?.feed_override); if (!Number.isFinite(feed) || feed < 0 || feed > 2) return finish(this.error(input, "motion_override", "motion_override_range"), "failed"); - return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", command: input.kind, feed_override: feed, state: { ...this.motion.export(), feed_override: feed }, motion: this.motionProjection(), motion_authority: "wasm", operation_id: input.operation_id ?? null }), "verified"); + try { + this.wasmMotion.setFeedOverride(feed); + } catch (error) { + return finish(this.error(input, "motion_override", String(error?.message ?? error)), "failed"); + } + const motion = this.wasmMotionProjection(); + return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", command: input.kind, feed_override: feed, overrides: { feed, spindle: [this.wasmMotionProjection().spindle_override] }, state: motion, motion, wasm_motion: this.wasmMotion.exportState(), motion_authority: "wasm", operation_id: input.operation_id ?? null }), "verified"); } if (input.kind === "FOLLOWING_ERROR") return finish(this.error(input, "motion_following_error", "wasm_following_error_unavailable"), "failed"); const current = this.wasmMotion.read(); diff --git a/web/worker/test_xyzac_feed_override.mjs b/web/worker/test_xyzac_feed_override.mjs new file mode 100644 index 0000000..ab4285d --- /dev/null +++ b/web/worker/test_xyzac_feed_override.mjs @@ -0,0 +1,65 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import createModule from "../../build-wasm/linuxcnc-sim-core.js"; +import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs"; +import { SimSession, CONTEXT_ID } from "./sim-session.mjs"; + +const CASE = "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini"; +const fetchImpl = async (url) => { + try { + const body = await readFile(new URL(`../../${String(url).replace(/^\//, "")}`, import.meta.url)); + return { ok: true, text: async () => body.toString("utf8") }; + } catch { + return { ok: false, text: async () => "" }; + } +}; + +const bundle = await loadSimulationCase({ path: CASE, fetchImpl }); +assert.equal(bundle.status, "ready"); +const core = await createModule({ wasmBinary: await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url)) }); +const session = new SimSession(core, { sessionId: "xyzac-feed-override" }); +const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId }; +let seq = 0; +const send = (kind, payload = {}) => session.accept({ ...base, kind, seq: ++seq, operation_id: `override-${seq}`, payload }); + +send("HELLO"); +send("SESSION_ATTACH"); +assert.equal(send("LOAD_WORKSPACE", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) }).payload.status, "verified"); +assert.equal(send("PROGRAM_LOAD", { path: "feed-override.ngc", source: "G21 G90\nG1 X10 F100\nM2\n" }).payload.status, "verified"); +assert.equal(send("POWER", { active: true }).payload.status, "verified"); +assert.equal(send("HOME", { all: true }).payload.status, "verified"); +assert.equal(send("SET_OVERRIDE", { feed: 0.5 }).payload.status, "verified"); +const half = send("PROGRAM_RUN"); +assert.equal(half.payload.status, "verified"); +const halfStep = send("SIM_STEP", { cycles: 3 }); +assert.equal(halfStep.payload.status, "verified"); +assert.equal(session.wasmMotion.controls.feed_override, 0.5); +assert.equal(halfStep.payload.panel_snapshot.overrides.feed, 0.5); +assert.ok(halfStep.payload.wasm_motion.state.velocity > 0.82 && halfStep.payload.wasm_motion.state.velocity < 0.85, JSON.stringify(halfStep.payload.wasm_motion.state)); + +const fullOverride = send("SET_OVERRIDE", { feed: 2 }); +assert.equal(fullOverride.payload.status, "verified"); +assert.equal(fullOverride.payload.panel_snapshot.overrides.feed, 2); +const fullStep = send("SIM_STEP", { cycles: 1 }); +assert.equal(session.wasmMotion.controls.feed_override, 2); +assert.ok(fullStep.payload.wasm_motion.state.velocity > 3.32 && fullStep.payload.wasm_motion.state.velocity < 3.35, JSON.stringify(fullStep.payload.wasm_motion.state)); + +const invalid = send("SET_OVERRIDE", { feed: 2.01 }); +assert.equal(invalid.kind, "ERROR"); +assert.equal(invalid.payload.code, "motion_override"); + +const checkpoint = send("CHECKPOINT"); +assert.equal(checkpoint.payload.status, "verified"); +const recovered = send("RECOVER", { checkpoint: checkpoint.payload.checkpoint }); +assert.ok(["verified", "recovered"].includes(recovered.payload.status)); +assert.equal(session.wasmMotion.controls.feed_override, 2); + +console.log(JSON.stringify({ + schema: "xyzac-feed-override.v1", + status: "verified", + authority: "linuxcnc-wasm-motion-queue", + half: { requested: 50, base_feed_mm_min: 100, actual: halfStep.payload.wasm_motion.state.velocity, panel: halfStep.payload.panel_snapshot.overrides.feed }, + full: { requested: 200, base_feed_mm_min: 100, actual: fullStep.payload.wasm_motion.state.velocity, panel: fullStep.payload.panel_snapshot.overrides.feed }, + invalid: invalid.payload.message_key, + recovery_feed: session.wasmMotion.controls.feed_override, +})); diff --git a/web/worker/wasm-motion-queue.mjs b/web/worker/wasm-motion-queue.mjs index b6047fc..ae8ebf5 100644 --- a/web/worker/wasm-motion-queue.mjs +++ b/web/worker/wasm-motion-queue.mjs @@ -6,7 +6,7 @@ const SEGMENT_INT_OFFSET = SEGMENT_DOUBLES * 2; const QUEUE_HEADER_BYTES = 32; const STATE_POSITION_BYTES = 72; const MOTION_QUEUE_SCHEMA = "sim_motion_queue_state.v1"; -const DEFAULT_CONTROLS = Object.freeze({ profile: 0, lookahead: 1, acceleration_limit: 1e9, jerk_limit: 1e12, paused: false }); +const DEFAULT_CONTROLS = Object.freeze({ profile: 0, lookahead: 1, acceleration_limit: 1e9, jerk_limit: 1e12, feed_override: 1, paused: false }); const MOTION_ERROR_REASONS = Object.freeze({ [-600]: "wasm_motion_invalid_argument", [-601]: "wasm_motion_nonfinite_value", [-602]: "wasm_motion_limit_range", [-603]: "wasm_motion_invalid_state", [-604]: "wasm_motion_queue_full", [-605]: "wasm_motion_paused", @@ -53,9 +53,10 @@ function controls(value = {}) { lookahead: value.lookahead ?? DEFAULT_CONTROLS.lookahead, acceleration_limit: value.acceleration_limit ?? DEFAULT_CONTROLS.acceleration_limit, jerk_limit: value.jerk_limit ?? DEFAULT_CONTROLS.jerk_limit, + feed_override: value.feed_override ?? DEFAULT_CONTROLS.feed_override, paused: value.paused ?? DEFAULT_CONTROLS.paused, }; - if (![0, 1].includes(result.profile) || !Number.isSafeInteger(result.lookahead) || result.lookahead < 1 || !Number.isFinite(result.acceleration_limit) || result.acceleration_limit <= 0 || !Number.isFinite(result.jerk_limit) || result.jerk_limit <= 0 || typeof result.paused !== "boolean") throw new Error("wasm_motion_controls_invalid"); + if (![0, 1].includes(result.profile) || !Number.isSafeInteger(result.lookahead) || result.lookahead < 1 || !Number.isFinite(result.acceleration_limit) || result.acceleration_limit <= 0 || !Number.isFinite(result.jerk_limit) || result.jerk_limit <= 0 || !Number.isFinite(result.feed_override) || result.feed_override < 0 || result.feed_override > 2 || typeof result.paused !== "boolean") throw new Error("wasm_motion_controls_invalid"); return result; } @@ -101,19 +102,22 @@ export class WasmMotionQueue { [...poseToArray(segment.start), ...poseToArray(segment.end), Number(segment.center?.x ?? 0), Number(segment.center?.y ?? 0), Number(segment.center?.z ?? 0), Number(segment.normal?.x ?? 0), Number(segment.normal?.y ?? 0), Number(segment.normal?.z ?? 0), Number(segment.velocity ?? 0), Number(segment.acceleration ?? 0)].forEach((value, index) => { doubles[base + index] = value; }); ints[this.command / 4 + SEGMENT_INT_OFFSET] = Number(segment.motion_type ?? 1); ints[this.command / 4 + SEGMENT_INT_OFFSET + 1] = Number(segment.turn ?? 0); - // sim_motion_command layout is segment (216 bytes), id (4), tag (4). + // sim_motion_command layout is segment (216 bytes), id (4), tag (4), flags (4). // Keep the WASM switchkins mode in the tag field; the C queue assigns id. ints[this.command / 4 + SEGMENT_INT_OFFSET + 3] = Number(segment.kinematics_mode ?? segment.tag ?? 0); + ints[this.command / 4 + SEGMENT_INT_OFFSET + 4] = segment.kind === "rapid" ? 1 : 0; const result = call(this.core, "sim_motion_enqueue", "number", ["number", "number", "number"], [this.queue, this.command, 0]); return result === 0 ? { status: "verified", source_line: segment.source_line ?? null } : { status: "error", code: `wasm_motion_enqueue_${result}`, reason: MOTION_ERROR_REASONS[result] ?? `wasm_motion_enqueue_${result}`, message_key: MOTION_ERROR_REASONS[result] ?? `wasm_motion_enqueue_${result}`, source: "linuxcnc-wasm-motion" }; } reset() { if (!this.available) return this.read(); + const previousFeedOverride = this.controls.feed_override; this.core.HEAPU8.fill(0, this.queue, this.queue + this.queueSize); const result = call(this.core, "sim_motion_init", "number", ["number", "number", "number"], [this.queue, this.cycleTime, this.velocityLimit]); if (result !== 0) throw new Error(`wasm_motion_reset_${result}`); this.controls = controls(); + this.setFeedOverride(previousFeedOverride); return this.read(); } @@ -144,6 +148,15 @@ export class WasmMotionQueue { return this.read(); } + setFeedOverride(feedOverride) { + const next = controls({ ...this.controls, feed_override: feedOverride }); + if (!this.available) { this.controls = next; return this.read(); } + const result = optionalCall(this.core, "sim_motion_set_feed_override", "number", ["number", "number"], [this.queue, next.feed_override]); + if (result !== null && result !== 0) throw new Error(`wasm_motion_feed_override_${result}`); + this.controls = next; + return this.read(); + } + setPaused(paused) { const next = controls({ ...this.controls, paused: Boolean(paused) }); if (!this.available) { this.controls = next; return this.read(); } @@ -158,11 +171,13 @@ export class WasmMotionQueue { const current = this.read(); if (current.status !== "verified") throw new Error("wasm_motion_abort_state"); const previous = this.core.HEAPU8.slice(this.queue, this.queue + this.queueSize); + const previousFeedOverride = this.controls.feed_override; const headerOffset = this.queueCapacity * this.commandCapacity; const stateOffset = headerOffset + QUEUE_HEADER_BYTES; const previousView = new DataView(previous.buffer, previous.byteOffset, previous.byteLength); const nextId = previousView.getUint32(headerOffset + 8, true); this.reset(); + this.setFeedOverride(previousFeedOverride); const bytes = new Uint8Array(this.queueSize); const view = new DataView(bytes.buffer); // sim_motion_queue stores commands first, followed by its 32-byte header. @@ -239,6 +254,7 @@ export class WasmMotionQueue { this.setProfile(nextControls.profile); this.setLookahead(nextControls.lookahead); this.setDynamics(nextControls.acceleration_limit, nextControls.jerk_limit); + this.setFeedOverride(nextControls.feed_override); this.setPaused(nextControls.paused); this.core.HEAPU8.set(bytes, this.queue); const actual = this.read();