Make feed override control the WASM trajectory

This commit is contained in:
2026-08-31 06:19:36 +08:00
parent 2cda55e724
commit 51ba923cb5
13 changed files with 181 additions and 15 deletions

View File

@@ -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):

View File

@@ -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()

View File

@@ -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);

View File

@@ -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 {

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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);

View File

@@ -189,9 +189,12 @@ export function App() {
const programFileInput = useRef<HTMLInputElement>(null);
const [plotCleared, setPlotCleared] = useState(false);
const [operationTrace, setOperationTrace] = useState<XyzacOperationTrace[]>([]);
const [feedOverrideDraft, setFeedOverrideDraft] = useState(100);
const feedOverridePending = useRef<number | null>(null);
const feedOverrideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const [previewProgress, setPreviewProgress] = useState<{ percent: number; phase: "reading" | "wasm" | "ready" | "error"; detail: string } | null>(null);
const previewProgressTimer = useRef<ReturnType<typeof setInterval> | 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<string, any> | null)?.coolant?.mist_requested), flood: Boolean((state.deviceIo as Record<string, any> | 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<HTMLInputElement>) => {
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<string, any> | 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() {
<MachineSequence isEstop={isEstop} machineOn={machineOn} allHomed={allHomed} programLoaded={programLoaded} programRunning={programRunning} programLoading={programLoading} canPower={gates.power.enabled} canHome={gates.home.enabled} canRun={gates.run.enabled} onEstop={emergencyStop} onRelease={() => { void releaseEstop().catch(() => undefined); }} onPower={powerOn} onHomeAll={homeAll} onRun={runProgram} onSequence={runStartupSequence} />
<XyzacTrtPanel view={xyzacView} command={xyzacCommand} />
<section className="axis-top-pane" data-axis-feature="AXIS-UI-06"><div className="axis-notebook left-notebook" data-axis-feature="AXIS-UI-06"><div className="notebook-tabs" role="tablist" aria-label="Manual, MDI and diagnostics controls"><button id="axis-tab-manual" type="button" role="tab" aria-controls="axis-panel-manual" aria-selected={leftTab === "manual"} className={leftTab === "manual" ? "active" : ""} onClick={enterManualMode}>Manual Control [F3]</button><button id="axis-tab-mdi" type="button" role="tab" aria-controls="axis-panel-mdi" aria-selected={leftTab === "mdi"} className={leftTab === "mdi" ? "active" : ""} onClick={() => setLeftTab("mdi")}>MDI [F5]</button><button id="axis-tab-diagnostics" type="button" role="tab" aria-controls="axis-panel-diagnostics" aria-selected={leftTab === "diagnostics"} className={leftTab === "diagnostics" ? "active" : ""} onClick={() => setLeftTab("diagnostics")}>Diagnostics</button></div><div id="axis-panel-manual" role="tabpanel" aria-labelledby="axis-tab-manual" hidden={leftTab !== "manual"}><ManualControl action={execute} ready={gates.jog.enabled} canHome={gates.home.enabled} onHomeAll={homeAll} selectedAxis={selectedAxis} setSelectedAxis={setSelectedAxis} spindleOn={spindleOn} coolant={coolant} onSpindle={onSpindle} onCoolant={onCoolant} devices={state.devices ?? {}} /></div><div id="axis-panel-mdi" role="tabpanel" aria-labelledby="axis-tab-mdi" hidden={leftTab !== "mdi"}><MdiControl action={execute} ready={gates.mdi.enabled} /></div><div id="axis-panel-diagnostics" role="tabpanel" aria-labelledby="axis-tab-diagnostics" hidden={leftTab !== "diagnostics"}><DiagnosticsPanel state={state} gates={gates} /><XyzacDiagnostics view={xyzacView} operations={operationTrace} alarms={state.alarms} error={state.error} /></div></div><div className="axis-notebook right-notebook" data-axis-feature="AXIS-UI-06"><div className="notebook-tabs" role="tablist" aria-label="Preview and DRO"><button id="axis-tab-preview" type="button" role="tab" aria-controls="axis-panel-preview" aria-selected={rightTab === "preview"} className={rightTab === "preview" ? "active" : ""} onClick={() => setRightTab("preview")}>Preview</button><button id="axis-tab-dro" type="button" role="tab" aria-controls="axis-panel-dro" aria-selected={rightTab === "dro"} className={rightTab === "dro" ? "active" : ""} onClick={() => setRightTab("dro")}>DRO</button></div><div id="axis-panel-preview" role="tabpanel" aria-labelledby="axis-tab-preview" className="axis-preview" hidden={rightTab !== "preview"}><XyzacToolPreviewFromSnapshot snapshot={programLoading ? null : state.snapshot} view={view} zoom={zoom} rotateMode={rotateMode} showPath={layers.path} showLive={layers.live} showLimits={layers.limits} showVelocity={layers.velocity} showOffsets={layers.offsets} showExtents={layers.extents} /></div><div id="axis-panel-dro" role="tabpanel" aria-labelledby="axis-tab-dro" hidden={rightTab !== "dro"}><DroView axes={axes} state={state} coordType={coordType} displayType={displayType} metric={metric} setCoordType={setCoordType} setDisplayType={setDisplayType} setMetric={setMetric} /></div></div></section>
<section className="axis-metrics" data-axis-feature="AXIS-VIEW-03"><div className="axis-slider"><label>Jog Speed:<output>50%</output><input type="range" min="6" max="100" defaultValue="50" /></label><label>Feed Override:<output>{Math.round((state.snapshot?.overrides?.feed ?? 1) * 100)}%</output><input type="range" min="0" max="120" defaultValue="100" onChange={(event) => execute("SET_OVERRIDE", { feed: Number(event.target.value) / 100 })} /></label><label>Spindle Override:<output>{Math.round((state.snapshot?.overrides?.spindle?.[0] ?? 1) * 100)}%</output><input type="range" min="0" max="120" defaultValue="100" /></label></div><div className="axis-layer-controls" aria-label="Preview layers">{(["path", "live", "limits", "velocity", "offsets", "extents"] as const).map((layer) => <label key={layer}><input type="checkbox" data-axis-feature="AXIS-VIEW-03" checked={layers[layer]} onChange={(event) => setLayers((current) => ({ ...current, [layer]: event.target.checked }))} /> {layer}</label>)}</div></section>
<section className="axis-metrics" data-axis-feature="AXIS-VIEW-03"><div className="axis-slider"><label>Jog Speed:<output>50%</output><input type="range" min="6" max="100" defaultValue="50" aria-label="Jog speed" /></label><label>Feed Override:<output>{feedOverrideDraft}%</output><input type="range" min="0" max="200" step="1" value={feedOverrideDraft} onChange={requestFeedOverride} disabled={!gates.override.enabled || programLoading} aria-label="Feed override" aria-valuetext={`${feedOverrideDraft}%`} data-axis-feed-override={feedOverrideDraft} /></label><label>Spindle Override:<output>{Math.round((state.snapshot?.overrides?.spindle?.[0] ?? 1) * 100)}%</output><input type="range" min="0" max="120" defaultValue="100" aria-label="Spindle override" /></label></div><div className="axis-layer-controls" aria-label="Preview layers">{(["path", "live", "limits", "velocity", "offsets", "extents"] as const).map((layer) => <label key={layer}><input type="checkbox" data-axis-feature="AXIS-VIEW-03" checked={layers[layer]} onChange={(event) => setLayers((current) => ({ ...current, [layer]: event.target.checked }))} /> {layer}</label>)}</div></section>
<XyzacProgramPane status={visibleProgramStatus} currentLine={visibleCurrentLine} sourceHash={programLoading ? "" : String(state.program?.source_hash ?? "")}><ProgramPane text={programText} setText={setProgramText} activeGcodes={activeGcodes} executionTrace={visibleExecutionTrace} programStatus={visibleProgramStatus} onLoad={loadProgram} onRun={runProgram} onStep={() => 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} /></XyzacProgramPane>
<input ref={programFileInput} className="visually-hidden-file-input" type="file" accept=".ngc,.nc,.gcode,.tap,text/plain" onChange={onProgramFile} aria-label="Open G-code file" />
<footer className="axis-info-bar" data-axis-feature="AXIS-UI-07"><span className={`task-state ${state.status}`} data-axis-feature="AXIS-SHELL-01">{taskState}</span><span className="machine-state" data-axis-state-machine={machineState}>Machine {machineState}</span><span className="operation-state" data-axis-state-operation={operationState}>Operation {operationState}</span><span className="tool-state" data-axis-feature="AXIS-UI-07">Tool {toolNumber}</span><span className="position-state" data-axis-feature="AXIS-VIEW-17">{position}</span><span className="footer-status" role="status">{state.error ? `Error: ${state.error.code}` : commandStatus}</span></footer>

View File

@@ -87,6 +87,7 @@ function projection(result: Record<string, unknown>, 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<string, unknown>) ?? 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<string, unknown>, 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<string, unknown>) ?? previous.task, program: programResult as Record<string, unknown> ?? previous.program, devices: (result.devices as Record<string, unknown>) ?? previous.devices, deviceIo: (result.device_io as Record<string, unknown>) ?? previous.deviceIo, hal: (result.hal_runtime as Record<string, unknown>) ?? previous.hal, plc: (result.plc as Record<string, unknown>) ?? previous.plc, python: (result.python as Record<string, unknown>) ?? previous.python, ioAck: (result.io_ack as Record<string, unknown>) ?? previous.ioAck, axisState: (result.axis_state as AxisStateView) ?? previous.axisState, gates: (result.gates as FrontendState["gates"]) ?? previous.gates, executionTrace };
}

View File

@@ -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();

View File

@@ -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,
}));

View File

@@ -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();