Files
linuxcncWebWASM/web/worker/authoritative-runtime.mjs

207 lines
9.3 KiB
JavaScript

import { fnv1a64 } from "./replay.mjs";
import { WEB_SETTINGS } from "../workspace/web-settings.mjs";
const CONFIG = WEB_SETTINGS.runtime.compatibility;
export const AUTHORITATIVE_RUNTIME_SCHEMA = "sim_authoritative_runtime.v1";
const AXES = Object.freeze(["x", "y", "z"]);
const STEP_FRACTION = CONFIG.step_fraction;
const KINEMATICS = new Set(["trivkins", "corexy", "tripod", "lineardelta", "rotarydelta"]);
const KINEMATICS_ALIASES = new Map([["corexykins", "corexy"], ["tripodkins", "tripod"], ["lineardeltakins", "lineardelta"], ["rotarydeltakins", "rotarydelta"]]);
const TRIPOD_BASE = CONFIG.tripod_base;
const LINEAR_DELTA_RADIUS = CONFIG.linear_delta.radius;
const LINEAR_DELTA_ROD = CONFIG.linear_delta.rod;
const ROTARY_DELTA_PLATFORM = CONFIG.rotary_delta.platform;
const ROTARY_DELTA_THIGH = CONFIG.rotary_delta.thigh;
const ROTARY_DELTA_SHIN = CONFIG.rotary_delta.shin;
const ROTARY_DELTA_FOOT = CONFIG.rotary_delta.foot;
const DEG_TO_RAD = Math.PI / 180;
const RAD_TO_DEG = 180 / Math.PI;
function clone(value) { return structuredClone(value); }
function finite(value) { return typeof value === "number" && Number.isFinite(value); }
function poseFromProgram(program) {
const state = program?.state;
if (!state || !AXES.every((axis) => finite(state[axis]))) return null;
return { x: state.x, y: state.y, z: state.z };
}
function samePose(left, right) { return left && right && AXES.every((axis) => left[axis] === right[axis]); }
function tripodJoints(pose) {
return TRIPOD_BASE.map(([x, y]) => Math.sqrt((pose.x - x) ** 2 + (pose.y - y) ** 2 + pose.z ** 2));
}
function linearDeltaJoints(pose) {
const radius = LINEAR_DELTA_RADIUS;
const rod = LINEAR_DELTA_ROD;
const bases = [[0, radius], [-Math.sqrt(3) * 0.5 * radius, -0.5 * radius], [Math.sqrt(3) * 0.5 * radius, -0.5 * radius]];
const joints = bases.map(([x, y]) => {
const radicand = rod * rod - (x - pose.x) ** 2 - (y - pose.y) ** 2;
return radicand < -1e-12 ? null : pose.z + Math.sqrt(Math.max(0, radicand));
});
return joints.some((value) => value === null || !finite(value)) ? null : joints;
}
function rotaryDeltaJoints(pose) {
if (Math.abs(pose.z) < 1e-12) return null;
const solve = (x, y) => {
const pfr = ROTARY_DELTA_PLATFORM; const tl = ROTARY_DELTA_THIGH; const sl = ROTARY_DELTA_SHIN; const fr = ROTARY_DELTA_FOOT;
const a = 0.5 * (x * x + (y - fr) ** 2 + pose.z ** 2 + tl ** 2 - sl ** 2 - pfr ** 2) / pose.z;
const b = (fr - pfr - y) / pose.z;
const d = tl ** 2 * (b ** 2 + 1) - (a - b * pfr) ** 2;
if (d < -1e-12) return null;
const kneeY = (pfr + a * b + Math.sqrt(Math.max(0, d))) / (b ** 2 + 1);
const kneeZ = b * kneeY - a;
return Math.atan2(kneeZ, kneeY - pfr) * RAD_TO_DEG;
};
const c = Math.cos(-2 * Math.PI / 3); const s = Math.sin(-2 * Math.PI / 3);
const c2 = Math.cos(2 * Math.PI / 3); const s2 = Math.sin(2 * Math.PI / 3);
const leg0 = solve(pose.x, pose.y);
const leg1 = solve(pose.x * c - pose.y * s, pose.x * s + pose.y * c);
const leg2 = solve(pose.x * c2 - pose.y * s2, pose.x * s2 + pose.y * c2);
return [leg0, leg1, leg2].some((value) => value === null || !finite(value)) ? null : [leg0, leg1, leg2];
}
function jointsFor(type, pose) {
if (type === "corexy") return [pose.x + pose.y, pose.x - pose.y, pose.z];
if (type === "tripod") return tripodJoints(pose);
if (type === "lineardelta") return linearDeltaJoints(pose);
if (type === "rotarydelta") return rotaryDeltaJoints(pose);
return [pose.x, pose.y, pose.z];
}
function mappingError(type, pose) { return type === "rotarydelta" && Math.abs(pose.z) < 1e-12 ? "authoritative_kinematics_singular" : "authoritative_kinematics_range"; }
function canonicalKinematicsType(type) { return KINEMATICS_ALIASES.get(type) ?? type; }
export class AuthoritativeRuntime {
constructor() { this.reset(); }
reset() {
const kinematicsType = this.state?.kinematics?.type ?? "trivkins";
this.state = {
schema: AUTHORITATIVE_RUNTIME_SCHEMA,
tick: 0,
status: "idle",
program_status: "idle",
kinematics: { type: kinematicsType, joint_count: 3, pose: { x: 0, y: 0, z: 0 }, joints: [0, 0, 0], status: 0, blocked: null },
planner: { next_id: 1, queue: [], active: null, active_id: null, progress: 0, queue_depth: 0, last_error: 0 },
};
return this.export();
}
export() {
const unsigned = clone(this.state);
return { ...unsigned, state_hash: fnv1a64(unsigned) };
}
hash() { return this.export().state_hash; }
setKinematics(type) {
const selected = canonicalKinematicsType(type);
if (!KINEMATICS.has(selected)) {
this.state.kinematics.blocked = "authoritative_kinematics_unsupported";
return { status: "blocked", code: "authoritative_kinematics_unsupported", type };
}
this.state.kinematics.type = selected;
this.state.kinematics.blocked = null;
const mapped = jointsFor(selected, this.state.kinematics.pose);
if (!mapped && selected === "rotarydelta" && Math.abs(this.state.kinematics.pose.z) < 1e-12) {
this.state.kinematics.type = selected;
this.state.kinematics.blocked = null;
this.state.kinematics.joints = [0, 0, 0];
return { status: "verified", type: selected };
}
if (!mapped) {
const code = mappingError(selected, this.state.kinematics.pose);
this.state.kinematics.blocked = code;
return { status: "blocked", code, type: selected };
}
this.state.kinematics.joints = mapped;
return { status: "verified", type: selected };
}
syncProgram(program) {
const target = poseFromProgram(program);
if (!target) return { status: "blocked", code: "authoritative_program_pose_missing" };
this.state.program_status = program.program_status ?? "idle";
if (this.state.program_status === "aborted") {
this.state.status = "aborted";
this.state.planner.queue = [];
this.state.planner.active_id = null;
this.state.planner.progress = 0;
this.state.planner.queue_depth = 0;
return { status: "verified", command: "AUTHORITATIVE_ABORT" };
}
const planner = this.state.planner;
const last = planner.queue.at(-1)?.end ?? planner.active?.end ?? (planner.active_id === null ? this.state.kinematics.pose : null);
if (!samePose(target, last)) {
const id = planner.next_id++;
planner.queue.push({ id, source_line: program.line ?? null, start: clone(this.state.kinematics.pose), end: target });
}
planner.queue_depth = planner.queue.length + (planner.active_id === null ? 0 : 1);
// M2 marks the interpreter complete, but AXIS remains visibly active until
// the final queued motion reaches its endpoint.
this.state.status = this.state.program_status === "completed" && planner.queue_depth === 0 ? "completed" : "running";
return { status: "verified", command: "AUTHORITATIVE_SYNC", queue_depth: planner.queue_depth };
}
abort() {
this.state.status = "aborted";
this.state.program_status = "aborted";
this.state.planner.queue = [];
this.state.planner.active = null;
this.state.planner.active_id = null;
this.state.planner.progress = 0;
this.state.planner.queue_depth = 0;
return this.export();
}
step(tick) {
if (!Number.isSafeInteger(tick) || tick < this.state.tick) return { status: "error", code: "authoritative_tick_regression" };
const planner = this.state.planner;
for (let current = this.state.tick + 1; current <= tick; current += 1) {
if (planner.active_id === null && planner.queue.length > 0) {
const next = planner.queue.shift();
planner.active_id = next.id;
planner.active = next;
planner.progress = 0;
}
if (planner.active_id !== null) {
planner.progress = Math.min(1, planner.progress + STEP_FRACTION);
const active = planner.active;
const pose = {};
for (const axis of AXES) pose[axis] = active.start[axis] + (active.end[axis] - active.start[axis]) * planner.progress;
this.state.kinematics.pose = pose;
const mapped = jointsFor(this.state.kinematics.type, pose);
if (!mapped) {
const code = mappingError(this.state.kinematics.type, pose);
this.state.kinematics.blocked = code;
return { status: "error", code };
}
this.state.kinematics.joints = mapped;
if (planner.progress >= 1) {
planner.active_id = null;
planner.active = null;
planner.progress = 0;
}
}
planner.queue_depth = planner.queue.length + (planner.active_id === null ? 0 : 1);
this.state.tick = current;
}
return { status: "verified", state: this.export() };
}
applyMotion(motion) {
if (!motion?.state) return;
const pose = this.state.kinematics.pose;
motion.state.axes = { ...pose };
motion.state.joints = [...this.state.kinematics.joints];
motion.state.tick = this.state.tick;
motion.state.velocity = this.state.planner.active_id === null ? 0 : STEP_FRACTION;
}
restore(value) {
if (!value || value.schema !== AUTHORITATIVE_RUNTIME_SCHEMA) throw new Error("authoritative_restore_schema");
const unsigned = clone(value);
delete unsigned.state_hash;
if (value.state_hash !== fnv1a64(unsigned)) throw new Error("authoritative_restore_hash");
if (!KINEMATICS.has(unsigned.kinematics?.type) || unsigned.kinematics?.joint_count !== 3) throw new Error("authoritative_restore_kinematics");
this.state = unsigned;
return this.export();
}
}