feat: sync latest run execution updates

This commit is contained in:
2026-06-22 21:47:16 -04:00
parent 0b1aad39e1
commit 8d3177cb73
92 changed files with 22837 additions and 246 deletions

View File

@@ -7,7 +7,7 @@
"build": "node scripts/build-static.mjs",
"dev": "python3 -m http.server 4173",
"smoke": "bash ../tests/browser/verify_gmoccapy_shell_browser.sh && bash ../tests/browser/verify_gmoccapy_dist_browser.sh",
"smoke:node": "node ../tests/node/verify_linuxcnc_kinematics_runtime.mjs && node ../tests/node/verify_linuxcnc_interpreter_runtime.mjs && node ../tests/node/verify_linuxcnc_ini_runtime.mjs && node ../tests/node/verify_run_preconditions.mjs && node ../tests/node/verify_linuxcnc_task_hal_runtime.mjs && node ../tests/node/verify_native_task_hal_audit.mjs && node ../tests/node/verify_full_linuxcnc_5axis_source.mjs && node ../tests/node/verify_real_linuxcnc_5axis_program_cases.mjs && node ../tests/node/verify_full_execution_boundary.mjs && node ../tests/node/verify_machine_file_staging.mjs && node ../tests/node/verify_five_axis_session.mjs && node ../tests/node/verify_rtcp_store.mjs && node ../tests/node/verify_profile_boundary.mjs"
"smoke:node": "node ../tests/node/verify_linuxcnc_kinematics_runtime.mjs && node ../tests/node/verify_linuxcnc_interpreter_runtime.mjs && node ../tests/node/verify_linuxcnc_ini_runtime.mjs && node ../tests/node/verify_run_preconditions.mjs && node ../tests/node/verify_run_feedback_loop.mjs && node ../tests/node/verify_linuxcnc_task_hal_runtime.mjs && node ../tests/node/verify_native_task_hal_audit.mjs && node ../tests/node/verify_full_linuxcnc_5axis_source.mjs && node ../tests/node/verify_real_linuxcnc_5axis_program_cases.mjs && node ../tests/node/verify_full_execution_boundary.mjs && node ../tests/node/verify_machine_file_staging.mjs && node ../tests/node/verify_five_axis_session.mjs && node ../tests/node/verify_rtcp_store.mjs && node ../tests/node/verify_profile_boundary.mjs && node ../tests/node/verify_linear_unit_conversion.mjs"
},
"dependencies": {},
"devDependencies": {}

View File

@@ -1,3 +1,9 @@
import {
linearUnitsToMillimetersFactor,
linearValueToMillimeters,
resolveStateLinearUnits,
} from "./linear-units.js";
const LINEAR_AXES = ["x", "y", "z", "u", "v", "w"];
const ANGULAR_AXES = ["a", "b", "c"];
const ALL_AXES = [...LINEAR_AXES, ...ANGULAR_AXES];
@@ -9,14 +15,17 @@ export function buildProgramExecutionTiming({
rapidOverride = 100,
defaultFeedRate = 100,
} = {}) {
const limits = buildVelocityLimits(profile);
const linearUnits = resolveStateLinearUnits(profile);
const limits = buildVelocityLimits(profile, linearUnits);
const segments = [];
let previousAxes = null;
let previousLinearUnits = linearUnits;
let elapsedSeconds = 0;
let feedRate = Number(defaultFeedRate) > 0 ? Number(defaultFeedRate) : 100;
for (let index = 0; index < motion.length; index += 1) {
const event = motion[index];
const eventLinearUnits = event.linearUnits || linearUnits;
const axes = normalizeAxes(event.axes, previousAxes);
if (Number.isFinite(event.feedRate) && event.feedRate > 0) {
feedRate = event.feedRate;
@@ -31,6 +40,8 @@ export function buildProgramExecutionTiming({
feedOverride,
rapidOverride,
elapsedSeconds,
linearUnits: eventLinearUnits,
previousLinearUnits: previousAxes ? previousLinearUnits : eventLinearUnits,
});
elapsedSeconds += segment.durationSeconds;
segments.push({
@@ -38,6 +49,7 @@ export function buildProgramExecutionTiming({
elapsedSeconds,
});
previousAxes = axes;
previousLinearUnits = eventLinearUnits;
}
const feedSeconds = segments
@@ -58,6 +70,7 @@ export function buildProgramExecutionTiming({
motionCount: segments.length,
segments,
limits,
linearUnits,
};
}
@@ -82,14 +95,22 @@ function buildTimingSegment({
feedOverride,
rapidOverride,
elapsedSeconds,
linearUnits,
previousLinearUnits,
}) {
const deltas = Object.fromEntries(ALL_AXES.map((axis) => [axis, axes[axis] - previousAxes[axis]]));
const linearDistanceMm = vectorLength(LINEAR_AXES.map((axis) => deltas[axis]));
const linearDistanceMm = vectorLength(LINEAR_AXES.map((axis) => (
linearValueToMillimeters(axes[axis], linearUnits)
- linearValueToMillimeters(previousAxes[axis], previousLinearUnits || linearUnits)
)));
const angularDistanceDeg = vectorLength(ANGULAR_AXES.map((axis) => deltas[axis]));
const motionClass = event.type === "STRAIGHT_TRAVERSE" ? "rapid" : "feed";
const feedMode = event.feedMode === "inverse-time" ? "inverse-time" : "units-per-minute";
const requestedLinearVelocity = motionClass === "rapid"
? limits.maxLinearVelocityMmPerMin * percent(rapidOverride)
: Math.max(feedRate, 0) * percent(feedOverride);
: feedMode === "inverse-time"
? inverseTimeVelocityMmPerMin(linearDistanceMm, angularDistanceDeg, feedRate)
: linearValueToMillimeters(Math.max(feedRate, 0), linearUnits) * percent(feedOverride);
const cappedLinearVelocity = Math.min(
requestedLinearVelocity || limits.defaultLinearVelocityMmPerMin,
limits.maxLinearVelocityMmPerMin,
@@ -99,17 +120,25 @@ function buildTimingSegment({
: 0;
const angularVelocityDegPerMin = motionClass === "rapid"
? limits.maxAngularVelocityDegPerMin * percent(rapidOverride)
: Math.min(Math.max(feedRate, 0) * percent(feedOverride), limits.maxAngularVelocityDegPerMin);
: feedMode === "inverse-time"
? inverseTimeAngularVelocityDegPerMin(angularDistanceDeg, feedRate)
: Math.min(Math.max(feedRate, 0) * percent(feedOverride), limits.maxAngularVelocityDegPerMin);
const angularSeconds = angularDistanceDeg > 0
? angularDistanceDeg / Math.max(angularVelocityDegPerMin / 60, 0.000001)
: 0;
const durationSeconds = Math.max(linearSeconds, angularSeconds);
const inverseTimeSeconds = motionClass === "feed" && feedMode === "inverse-time" && feedRate > 0
? 60 / feedRate
: 0;
const durationSeconds = inverseTimeSeconds > 0
? inverseTimeSeconds
: Math.max(linearSeconds, angularSeconds);
return {
index,
line: event.line,
type: event.type,
motionClass,
feedMode,
linearDistanceMm,
angularDistanceDeg,
feedRate,
@@ -118,20 +147,28 @@ function buildTimingSegment({
angularVelocityDegPerMin,
durationSeconds,
startSeconds: elapsedSeconds,
startAxes: previousAxes,
endAxes: axes,
axes,
deltas,
linearUnits,
};
}
function buildVelocityLimits(profile) {
function buildVelocityLimits(profile, linearUnits) {
const traj = profile?.traj || {};
const axisLimits = profile?.axisLimits || {};
const linearVelocityScale = linearUnitsToMillimetersFactor(linearUnits);
const maxLinearVelocity = firstFinite(
Number(traj.maxLinearVelocity) * 60,
...LINEAR_AXES.map((axis) => Number(axisLimits[axis.toUpperCase()]?.maxVelocity) * 60),
Number(traj.maxLinearVelocity) * linearVelocityScale * 60,
...LINEAR_AXES.map((axis) => Number(axisLimits[axis.toUpperCase()]?.maxVelocity) * linearVelocityScale * 60),
2100,
);
const defaultLinearVelocity = firstFinite(Number(traj.defaultLinearVelocity) * 60, maxLinearVelocity, 1200);
const defaultLinearVelocity = firstFinite(
Number(traj.defaultLinearVelocity) * linearVelocityScale * 60,
maxLinearVelocity,
1200,
);
const maxAngularVelocity = firstFinite(
...ANGULAR_AXES.map((axis) => Number(axisLimits[axis.toUpperCase()]?.maxVelocity) * 60),
maxLinearVelocity,
@@ -161,6 +198,24 @@ function percent(value) {
return Number.isFinite(number) ? Math.max(number, 0) / 100 : 1;
}
function inverseTimeVelocityMmPerMin(linearDistanceMm, angularDistanceDeg, feedRate) {
const durationSeconds = feedRate > 0 ? 60 / feedRate : 0;
if (linearDistanceMm > 0 && durationSeconds > 0) {
return (linearDistanceMm / durationSeconds) * 60;
}
if (angularDistanceDeg > 0 && durationSeconds > 0) {
return angularDistanceDeg / durationSeconds * 60;
}
return 0;
}
function inverseTimeAngularVelocityDegPerMin(angularDistanceDeg, feedRate) {
const durationSeconds = feedRate > 0 ? 60 / feedRate : 0;
return angularDistanceDeg > 0 && durationSeconds > 0
? angularDistanceDeg / durationSeconds * 60
: 0;
}
function firstFinite(...values) {
return values.find((value) => Number.isFinite(value) && value > 0) || 1;
}

View File

@@ -0,0 +1,60 @@
const UNIT_ALIASES = new Map([
["mm", "mm"],
["millimeter", "mm"],
["millimeters", "mm"],
["millimetre", "mm"],
["millimetres", "mm"],
["metric", "mm"],
["inch", "inch"],
["inches", "inch"],
["in", "inch"],
["imperial", "inch"],
["m", "m"],
["meter", "m"],
["meters", "m"],
["metre", "m"],
["metres", "m"],
]);
const METERS_PER_UNIT = {
mm: 0.001,
inch: 0.0254,
m: 1,
};
export function normalizeLinearUnits(units, fallback = "mm") {
const key = String(units || "").trim().toLowerCase();
return UNIT_ALIASES.get(key) || UNIT_ALIASES.get(String(fallback || "mm").trim().toLowerCase()) || "mm";
}
export function linearUnitsToMetersFactor(units) {
return METERS_PER_UNIT[normalizeLinearUnits(units)] || METERS_PER_UNIT.mm;
}
export function linearUnitsToMillimetersFactor(units) {
return linearUnitsToMetersFactor(units) * 1000;
}
export function linearValueToMeters(value, units) {
const number = Number(value) || 0;
return number * linearUnitsToMetersFactor(units);
}
export function linearValueToMillimeters(value, units) {
const number = Number(value) || 0;
return number * linearUnitsToMillimetersFactor(units);
}
export function linearUnitsLabel(units) {
return normalizeLinearUnits(units);
}
export function resolveStateLinearUnits(stateOrProfile, fallback = "mm") {
return normalizeLinearUnits(
stateOrProfile?.programExecution?.summary?.linearUnits
|| stateOrProfile?.linuxCncIniConfig?.traj?.linearUnits
|| stateOrProfile?.profile?.traj?.linearUnits
|| stateOrProfile?.traj?.linearUnits,
fallback,
);
}

View File

@@ -11,6 +11,17 @@ export function parseLinuxCncIni(text, { path = "inline.ini", profileId = "unkno
const remaps = parseRemaps(sections);
const hal = parseHal(sections);
const display = parseDisplay(sections);
const emcmot = {
module: getFirstValue(sections, "EMCMOT", "EMCMOT") || null,
servoPeriodNs: numberOrNull(getFirstValue(sections, "EMCMOT", "SERVO_PERIOD")),
};
const task = {
module: getFirstValue(sections, "TASK", "TASK") || null,
cycleTimeSeconds: numberOrNull(getFirstValue(sections, "TASK", "CYCLE_TIME")),
};
const emcio = {
toolTable: getFirstValue(sections, "EMCIO", "TOOL_TABLE") || null,
};
const halui = {
mdiCommands: getValues(sections, "HALUI", "MDI_COMMAND"),
};
@@ -20,6 +31,7 @@ export function parseLinuxCncIni(text, { path = "inline.ini", profileId = "unkno
apiName: "web-rtcp-5axis-linuxcnc-ini-config",
profileId,
path,
sourceText: text,
machineName: getFirstValue(sections, "EMC", "MACHINE") || null,
kinematics: parseKinematics(kinsText),
kinematicsModuleId,
@@ -48,10 +60,27 @@ export function parseLinuxCncIni(text, { path = "inline.ini", profileId = "unkno
halui,
axisLimits,
jointConfig,
emcio: {
toolTable: getFirstValue(sections, "EMCIO", "TOOL_TABLE") || null,
},
validation: validateIniConfig({ coordinates, jointCount, axisLimits, jointConfig, kinsText }),
emcmot,
task,
emcio,
validation: validateIniConfig({
sections,
coordinates,
jointCount,
axisLimits,
jointConfig,
kinsText,
remaps,
hal,
halui,
rs274ngc: {
halPinVars: boolFromIni(getFirstValue(sections, "RS274NGC", "HAL_PIN_VARS")),
parameterFile: getFirstValue(sections, "RS274NGC", "PARAMETER_FILE") || null,
},
emcmot,
task,
emcio,
}),
semanticBoundary: "linuxcnc_ini_file_browser_parser",
};
}
@@ -130,6 +159,18 @@ export function applyIniConfigToProfile(profile, iniConfig) {
},
},
halui: iniConfig.halui.mdiCommands.length > 0 ? iniConfig.halui : profile.halui,
emcmot: {
...profile.emcmot,
...dropNullish(iniConfig.emcmot || {}),
},
task: {
...profile.task,
...dropNullish(iniConfig.task || {}),
},
emcio: {
...profile.emcio,
...dropNullish(iniConfig.emcio || {}),
},
traj: {
...profile.traj,
...dropNullish(iniConfig.traj),
@@ -297,17 +338,73 @@ function inferSwitchkinsTypes({ coordinates, halui, kinematicsModuleId }) {
}));
}
function validateIniConfig({ coordinates, jointCount, axisLimits, jointConfig, kinsText }) {
function validateIniConfig({
sections,
coordinates,
jointCount,
axisLimits,
jointConfig,
kinsText,
remaps,
hal,
halui,
rs274ngc,
emcmot,
task,
emcio,
}) {
const missing = [];
const requiredSections = [
"EMC",
"DISPLAY",
"RS274NGC",
"KINS",
"HAL",
"HALUI",
"TRAJ",
"EMCMOT",
"TASK",
"EMCIO",
];
for (const section of requiredSections) {
if (!sections.has(section)) missing.push(`[${section}]`);
}
if (!["XYZAC", "XYZBC"].includes(coordinates)) missing.push("TRAJ.COORDINATES XYZAC/XYZBC");
if (!coordinates) missing.push("TRAJ.COORDINATES");
if (!jointCount) missing.push("KINS.JOINTS");
if (jointCount !== 5) missing.push("KINS.JOINTS=5");
if (!kinsText) missing.push("KINS.KINEMATICS");
if (!String(kinsText || "").includes("sparm=identityfirst")) {
missing.push("KINS.KINEMATICS sparm=identityfirst");
}
for (const axis of String(coordinates || "").split("")) {
if (!axisLimits[axis]) missing.push(`AXIS_${axis}`);
}
if (jointCount && jointConfig.length !== jointCount) {
missing.push(`JOINT_ count ${jointConfig.length}/${jointCount}`);
}
for (let joint = 0; joint < 5; joint += 1) {
if (!sections.has(`JOINT_${joint}`)) missing.push(`JOINT_${joint}`);
}
for (const code of ["M428", "M429", "M430"]) {
if (!remaps.some((remap) => remap.code === code && remap.ngc)) {
missing.push(`RS274NGC.REMAP ${code}`);
}
}
if (rs274ngc.halPinVars !== true) missing.push("RS274NGC.HAL_PIN_VARS=1");
if (!rs274ngc.parameterFile) missing.push("RS274NGC.PARAMETER_FILE");
if (!hal.halui) missing.push("HAL.HALUI");
if (!hal.halFiles.length) missing.push("HAL.HALFILE");
if (!hal.postguiHalFiles.length) missing.push("HAL.POSTGUI_HALFILE");
if (!hal.halcmd.some((line) => line.includes("motion.analog-out-03") && line.includes("motion.switchkins-type"))) {
missing.push("HAL.HALCMD motion.analog-out-03=>motion.switchkins-type");
}
if (halui.mdiCommands.length < 3) missing.push("HALUI.MDI_COMMAND M428/M429/M430");
if (!emcmot.module) missing.push("EMCMOT.EMCMOT");
if (!emcmot.servoPeriodNs) missing.push("EMCMOT.SERVO_PERIOD");
if (!task.module) missing.push("TASK.TASK");
if (!task.cycleTimeSeconds) missing.push("TASK.CYCLE_TIME");
if (!emcio.toolTable) missing.push("EMCIO.TOOL_TABLE");
return {
ready: missing.length === 0,
missing,

View File

@@ -234,11 +234,15 @@ export function parseLinuxCncCanonicalMotion(resultText, programText = "", switc
const axes = Object.fromEntries(AXES.map((axis) => [axis, 0]));
const sourceLines = programLineMap(programText);
const feedRatesByLine = feedRatesBySourceLine(programText);
const feedModesByLine = feedModesBySourceLine(programText);
const linearUnitsByLine = linearUnitsBySourceLine(programText);
const switchkinsByLine = switchkinsEventsByLine(switchkinsEvents);
const motion = [];
let activePlane = 170;
let activeSwitchkinsEvent = null;
let activeFeedRate = null;
let activeFeedMode = "units-per-minute";
let activeLinearUnits = "mm";
for (const line of String(resultText).split("\n")) {
const feedRate = readCanonicalNumber(line, "feed_rate");
@@ -289,6 +293,8 @@ export function parseLinuxCncCanonicalMotion(resultText, programText = "", switc
if (Number.isFinite(sourceFeedRate) && sourceFeedRate > 0) {
activeFeedRate = sourceFeedRate;
}
activeFeedMode = latestFeedModeAtOrBeforeLine(feedModesByLine, sourceLine) || activeFeedMode;
activeLinearUnits = latestLinearUnitsAtOrBeforeLine(linearUnitsByLine, sourceLine) || activeLinearUnits;
}
motion.push({
type: event[1],
@@ -300,6 +306,8 @@ export function parseLinuxCncCanonicalMotion(resultText, programText = "", switc
switchkinsCode: activeSwitchkinsEvent?.code || null,
switchkinsRemapBoundary: activeSwitchkinsEvent ? SWITCHKINS_REMAP_BOUNDARY : null,
feedRate: activeFeedRate,
feedMode: activeFeedMode,
linearUnits: activeLinearUnits,
raw: line,
});
}
@@ -307,6 +315,56 @@ export function parseLinuxCncCanonicalMotion(resultText, programText = "", switc
return motion;
}
function feedModesBySourceLine(programText) {
const modes = [{ line: 0, feedMode: "units-per-minute" }];
String(programText).split(/\r?\n/).forEach((line, index) => {
const codeOnly = stripComments(line);
let activeMode = null;
for (const match of codeOnly.matchAll(/\bG\s*([0-9]+(?:\.[0-9]+)?)\b/gi)) {
const value = Number(match[1]);
if (value === 93) activeMode = "inverse-time";
if (value === 94) activeMode = "units-per-minute";
}
if (activeMode) {
modes.push({ line: index + 1, feedMode: activeMode });
}
});
return modes;
}
function latestFeedModeAtOrBeforeLine(modes, sourceLine) {
let feedMode = "units-per-minute";
for (const entry of modes) {
if (entry.line <= sourceLine) feedMode = entry.feedMode;
}
return feedMode;
}
function linearUnitsBySourceLine(programText) {
const units = [{ line: 0, linearUnits: "mm" }];
String(programText).split(/\r?\n/).forEach((line, index) => {
const codeOnly = stripComments(line);
let activeUnits = null;
for (const match of codeOnly.matchAll(/\bG\s*([0-9]+(?:\.[0-9]+)?)\b/gi)) {
const value = Number(match[1]);
if (value === 20) activeUnits = "inch";
if (value === 21) activeUnits = "mm";
}
if (activeUnits) {
units.push({ line: index + 1, linearUnits: activeUnits });
}
});
return units;
}
function latestLinearUnitsAtOrBeforeLine(units, sourceLine) {
let linearUnits = "mm";
for (const entry of units) {
if (entry.line <= sourceLine) linearUnits = entry.linearUnits;
}
return linearUnits;
}
function feedRatesBySourceLine(programText) {
const rates = [];
String(programText).split(/\r?\n/).forEach((line, index) => {

View File

@@ -93,6 +93,17 @@ export function wrapTaskHalSdk(sdk, {
return rc;
},
loadProgramMotionPlan(plan = {}) {
if (typeof sdk.loadProgramMotionPlan !== "function") {
throw new Error("task/HAL SDK missing loadProgramMotionPlan; rebuild wasm-port/tools/build_task_hal_wasm.sh");
}
const rc = sdk.loadProgramMotionPlan(plan);
if (rc !== 0) {
throw new Error(`lctask_load_program_motion_plan_json failed rc=${rc}`);
}
return rc;
},
sendCommand(command) {
const rc = sdk.sendCommand(command);
if (rc !== 0) {
@@ -152,10 +163,65 @@ export function buildTaskHalSessionFromMachineFiles({ profile, plan, save, selec
};
}
export function buildTaskHalProgramMotionPlan({
programPath = null,
motion = [],
timing = null,
linearUnits = "mm",
programLines = [],
} = {}) {
const segments = Array.isArray(timing?.segments) ? timing.segments : [];
let planSegments = segments.map((segment, index) => {
const event = motion[index] || {};
const startAxes = normalizePlanAxes(segment.startAxes || motion[index - 1]?.axes || {});
const endAxes = normalizePlanAxes(segment.endAxes || segment.axes || event.axes || startAxes);
return {
line: Number(segment.line ?? event.line ?? index + 1),
type: segment.type || event.type || "STRAIGHT_FEED",
motionClass: segment.motionClass || (event.type === "STRAIGHT_TRAVERSE" ? "rapid" : "feed"),
feedMode: segment.feedMode || event.feedMode || "units-per-minute",
startSeconds: Number(segment.startSeconds || 0),
durationSeconds: Math.max(Number(segment.durationSeconds || 0), 0),
elapsedSeconds: Number(segment.elapsedSeconds || 0),
feedRate: Number(segment.feedRate || event.feedRate || 0),
linearUnits: segment.linearUnits || event.linearUnits || linearUnits,
velocityMmPerMin: Math.max(Number(segment.velocityMmPerMin || 0), 0),
requestedVelocityMmPerMin: Math.max(Number(segment.requestedVelocityMmPerMin || segment.velocityMmPerMin || 0), 0),
startAxes,
endAxes,
};
}).filter((segment) => segment.durationSeconds > 0 || segment.line > 0);
const lineSegments = buildSourceLineMotionSegments({
programLines,
seedSegments: planSegments,
linearUnits,
});
if (shouldUseSourceLineSegments(planSegments, lineSegments)) {
planSegments = lineSegments;
}
return {
apiName: "web-rtcp-5axis-task-hal-program-motion-plan",
semanticBoundary: "linuxcnc_canonical_motion_feed_timed_task_hal_plan",
programPath,
linearUnits,
totalSeconds: Number(timing?.totalSeconds || 0),
segmentCount: planSegments.length,
segments: planSegments,
};
}
export function normalizeTaskHalStatus(status = {}) {
const motion = status.motionStatus?.motion || {};
const axis = status.motionStatus?.axis || {};
const halPins = status.halSnapshot?.pins || {};
const motionProgramLine = Number(motion.programLine || 0);
const halProgramLine = Number(halPins["motion.program-line"]?.value || 0);
const activeLine = motionProgramLine > 0
? motionProgramLine
: halProgramLine > 0
? halProgramLine
: 1;
return {
...status,
semanticBoundary: SEMANTIC_BOUNDARY,
@@ -183,7 +249,13 @@ export function normalizeTaskHalStatus(status = {}) {
halChangedPinCount: Array.isArray(status.halSnapshot?.changedPins)
? status.halSnapshot.changedPins.length
: Number(status.halSnapshot?.changedPinCount || 0),
activeLine: Number(motion.programLine || halPins["motion.program-line"]?.value || 1),
activeLine,
motionProgramLine,
halProgramLine,
activeLineSource: motionProgramLine > 0 ? "motion-status" : halProgramLine > 0 ? "hal-pin" : "fallback",
activeLineHalSynced: motionProgramLine > 0 && halProgramLine > 0
? motionProgramLine === halProgramLine
: false,
switchkinsType: Number(motion.switchkinsType ?? halPins["motion.switchkins-type"]?.value ?? 0),
axisPose: {
x: Number(axis.x ?? halPins["axis.0.pos-cmd"]?.value ?? 0),
@@ -199,6 +271,133 @@ export function normalizeTaskHalStatus(status = {}) {
};
}
function shouldUseSourceLineSegments(planSegments, lineSegments) {
if (lineSegments.length < 3) return false;
const plannedLines = new Set(planSegments.map((segment) => Number(segment.line)).filter(Number.isFinite));
const lineCount = lineSegments.length;
return plannedLines.size <= 2 && lineCount > plannedLines.size;
}
function buildSourceLineMotionSegments({
programLines = [],
seedSegments = [],
linearUnits = "mm",
} = {}) {
if (!Array.isArray(programLines) || programLines.length === 0) return [];
const seedByLine = new Map(seedSegments.map((segment) => [Number(segment.line), segment]));
const axes = normalizePlanAxes(seedSegments[0]?.startAxes || {});
const segments = [];
let elapsedSeconds = 0;
let feedRate = firstPositive(seedSegments.map((segment) => segment.feedRate), 100);
let rapidVelocity = firstPositive(
seedSegments.filter((segment) => segment.motionClass === "rapid").map((segment) => segment.velocityMmPerMin),
2100,
);
for (let index = 0; index < programLines.length; index += 1) {
const line = index + 1;
const code = stripSourceLineComments(programLines[index]);
if (!isExecutableGcodeLine(code)) continue;
const seed = seedByLine.get(line) || null;
const startAxes = normalizePlanAxes(seed?.startAxes || axes);
const parsed = parseGcodeLineMotion(code, startAxes, feedRate);
if (parsed.feedRate > 0) feedRate = parsed.feedRate;
const motionClass = seed?.motionClass || parsed.motionClass;
const endAxes = normalizePlanAxes(seed?.endAxes || parsed.endAxes);
const velocityMmPerMin = Number(seed?.velocityMmPerMin) > 0
? Number(seed.velocityMmPerMin)
: motionClass === "rapid"
? rapidVelocity
: Math.max(feedRate, 1);
if (motionClass === "rapid" && velocityMmPerMin > 0) {
rapidVelocity = velocityMmPerMin;
}
const durationSeconds = Math.max(
Number(seed?.durationSeconds || 0),
estimateLineDurationSeconds(startAxes, endAxes, velocityMmPerMin),
0.05,
);
const segment = {
line,
type: seed?.type || parsed.type,
motionClass,
feedMode: seed?.feedMode || parsed.feedMode,
startSeconds: elapsedSeconds,
durationSeconds,
elapsedSeconds: elapsedSeconds + durationSeconds,
feedRate,
linearUnits: seed?.linearUnits || linearUnits,
velocityMmPerMin,
requestedVelocityMmPerMin: Number(seed?.requestedVelocityMmPerMin || velocityMmPerMin),
startAxes,
endAxes,
};
segments.push(segment);
Object.assign(axes, endAxes);
elapsedSeconds += durationSeconds;
}
return segments;
}
function parseGcodeLineMotion(code, startAxes, currentFeedRate) {
const numberPattern = "[-+]?(?:\\d+(?:\\.\\d*)?|\\.\\d+)";
const gCodes = [...code.matchAll(new RegExp(`\\bG\\s*(${numberPattern})\\b`, "gi"))].map((match) => Number(match[1]));
const feedMode = gCodes.includes(93) ? "inverse-time" : "units-per-minute";
const rapid = gCodes.includes(0);
const feed = gCodes.some((value) => value === 1 || value === 2 || value === 3);
const endAxes = { ...startAxes };
for (const axis of ["x", "y", "z", "a", "b", "c", "u", "v", "w"]) {
const match = code.match(new RegExp(`\\b${axis}\\s*(${numberPattern})`, "i"));
if (match) endAxes[axis] = Number(match[1]);
}
const feedMatch = code.match(new RegExp(`\\bF\\s*(${numberPattern})`, "i"));
const feedRate = feedMatch && Number(feedMatch[1]) > 0 ? Number(feedMatch[1]) : Number(currentFeedRate || 0);
return {
type: rapid ? "STRAIGHT_TRAVERSE" : "STRAIGHT_FEED",
motionClass: rapid && !feed ? "rapid" : "feed",
feedMode,
feedRate,
endAxes,
};
}
function stripSourceLineComments(line) {
return String(line || "")
.replace(/\([^)]*\)/g, " ")
.replace(/;.*$/g, " ")
.trim();
}
function isExecutableGcodeLine(code) {
if (!code || code === "%") return false;
return /\b[GMTXYZABCUVWF]\s*[-+]?\d/i.test(code);
}
function estimateLineDurationSeconds(startAxes, endAxes, velocityMmPerMin) {
const distance = Math.sqrt(["x", "y", "z"].reduce((total, axis) => {
const delta = Number(endAxes[axis] || 0) - Number(startAxes[axis] || 0);
return total + delta * delta;
}, 0));
if (distance <= 0 || velocityMmPerMin <= 0) return 0;
return distance / Math.max(velocityMmPerMin / 60, 0.000001);
}
function firstPositive(values, fallback) {
for (const value of values) {
const number = Number(value);
if (Number.isFinite(number) && number > 0) return number;
}
return fallback;
}
function normalizePlanAxes(axes = {}) {
return Object.fromEntries(["x", "y", "z", "a", "b", "c", "u", "v", "w"].map((axis) => [
axis,
Number.isFinite(Number(axes[axis])) ? Number(axes[axis]) : 0,
]));
}
function isJogMotion(motion = {}) {
return Number(motion.motionType) === 3 || Number(motion.teleopMode) === 1 || motion.teleopMode === true;
}

View File

@@ -21,6 +21,7 @@ export async function createLinuxCncTaskHalWorkerRuntime({
initSession: (session) => client.call("initSession", { session }),
stageFiles: (files) => client.call("stageFiles", { files }),
openProgram: (path) => client.call("openProgram", { path }),
loadProgramMotionPlan: (plan) => client.call("loadProgramMotionPlan", { plan }),
sendCommand: (command) => client.call("command", { command }),
runCycles: (options) => client.call("runCycles", { options }),
readStatus: () => client.call("readStatus"),

View File

@@ -33,6 +33,9 @@ async function handleMessage(type, payload) {
case "openProgram":
assertRuntime();
return runtime.openProgram(payload.path);
case "loadProgramMotionPlan":
assertRuntime();
return runtime.loadProgramMotionPlan(payload.plan || {});
case "command":
assertRuntime();
return runtime.sendCommand(payload.command);

View File

@@ -17,6 +17,7 @@ import {
stageProfileMachineFiles,
} from "../runtime/linuxcnc-machine-file-staging.js";
import {
buildTaskHalProgramMotionPlan,
buildTaskHalSessionFromMachineFiles,
} from "../runtime/linuxcnc-task-hal-runtime.js";
import {
@@ -42,6 +43,40 @@ const initialAxisPose = {
c: 0.0,
};
function createTaskHalStatusLoopState({
active = false,
sequence = 0,
profileId = null,
iniPath = null,
kinematicsModuleId = null,
tickCount = 0,
batchSize = 5,
intervalMs = 25,
taskPeriodNs = 10000000,
servoPeriodNs = 1000000,
lastStatusAt = null,
lastError = null,
stopReason = null,
} = {}) {
return {
apiName: "web-rtcp-5axis-task-hal-status-loop",
active,
sequence,
profileId,
iniPath,
kinematicsModuleId,
tickCount,
batchSize,
intervalMs,
taskPeriodNs,
servoPeriodNs,
lastStatusAt,
lastError,
stopReason,
semanticBoundary: "js_status_polling_loop_for_linuxcnc_task_hal_motion_status",
};
}
const initialState = {
machineProfile: "xyzac-trt",
availableProfiles: fiveAxisProfiles.map(({ id, title, traj, kinematicsModuleId, kinematics }) => ({
@@ -136,12 +171,14 @@ const initialState = {
programExecutionMotionIndex: 0,
programExecutionSampleIndex: 0,
programRuntimeFeedback: null,
programRuntimeFeedbackHistory: [],
taskHalRuntime: null,
taskHalRuntimeReadiness: null,
taskHalStatus: null,
taskHalSession: null,
taskHalExecutionPending: false,
taskHalExecutionSequence: 0,
taskHalStatusLoop: createTaskHalStatusLoopState(),
taskHalFallbackReason: null,
pendingJogCommand: null,
interpreterExecutionPending: false,
@@ -249,6 +286,7 @@ export function createSimulationStore(seed = {}) {
};
state.fullExecutionBoundary = createFullLinuxCncExecutionBoundary(state);
const listeners = new Set();
let taskHalStatusLoopTimer = null;
const notify = () => {
for (const listener of listeners) {
@@ -772,7 +810,7 @@ export function createSimulationStore(seed = {}) {
},
machine: {
...state.machine,
mode: "auto",
mode: state.machine.mode,
},
axisPose: initialAxisPose,
runState: "idle",
@@ -799,12 +837,51 @@ export function createSimulationStore(seed = {}) {
});
break;
case "TASK_HAL_STATUS_APPLIED":
setState(applyTaskHalStatusPatch(state, action.status, action.operatorMessage));
setState(applyTaskHalStatusPatch(state, action.status, action.operatorMessage, {
loopSequence: action.loopSequence,
preserveMachine: action.preserveMachine,
}));
break;
case "TASK_HAL_STATUS_LOOP_STARTED":
setState({
taskHalStatusLoop: {
...createTaskHalStatusLoopState({
active: true,
sequence: action.sequence,
profileId: action.profileId,
iniPath: action.iniPath,
kinematicsModuleId: action.kinematicsModuleId,
batchSize: action.batchSize,
intervalMs: action.intervalMs,
taskPeriodNs: action.taskPeriodNs,
servoPeriodNs: action.servoPeriodNs,
}),
},
programRuntimeFeedbackHistory: [],
operatorMessage: action.operatorMessage || "task/HAL status loop running",
});
break;
case "TASK_HAL_STATUS_LOOP_STOPPED":
setState({
taskHalStatusLoop: {
...state.taskHalStatusLoop,
active: false,
stopReason: action.reason || "stopped",
lastError: action.error || null,
},
operatorMessage: action.operatorMessage || state.operatorMessage,
});
break;
case "TASK_HAL_COMMAND_FAILED":
setState({
taskHalFallbackReason: action.error,
taskHalExecutionPending: false,
taskHalStatusLoop: {
...state.taskHalStatusLoop,
active: false,
lastError: action.error,
stopReason: "error",
},
pendingJogCommand: null,
operatorMessage: `task/HAL fallback: ${action.error}`,
});
@@ -850,13 +927,29 @@ export function createSimulationStore(seed = {}) {
setState({ operatorMessage: gate.operatorMessage });
break;
}
const turningOff = state.machine.taskState === "on" || state.machine.powerOn;
if (state.taskHalRuntime?.loaded) {
setState({
machine: {
...state.machine,
powerOn: !turningOff,
estopActive: false,
taskState: turningOff ? "estop-reset" : "on",
interpState: "idle",
interpResumeState: "idle",
taskPaused: false,
},
runState: turningOff ? "powered-off" : "idle",
feed: turningOff ? { ...state.feed, currentVelocity: 0 } : state.feed,
coolant: turningOff ? { ...state.coolant, flood: false, mist: false } : state.coolant,
spindle: turningOff ? { ...state.spindle, enabled: false } : state.spindle,
operatorMessage: turningOff ? "task/HAL machine power off" : "task/HAL machine power on",
});
runTaskHalCommandSequence([
{ type: "EMC_TASK_SET_STATE", state: state.machine.powerOn ? "ESTOP_RESET" : "ON" },
], { operatorMessage: state.machine.powerOn ? "task/HAL machine power off" : "task/HAL machine power on" }).catch(() => {});
{ type: "EMC_TASK_SET_STATE", state: turningOff ? "ESTOP_RESET" : "ON" },
], { operatorMessage: turningOff ? "task/HAL machine power off" : "task/HAL machine power on" }).catch(() => {});
break;
}
const turningOff = state.machine.taskState === "on" || state.machine.powerOn;
setState({
machine: {
...state.machine,
@@ -1108,6 +1201,9 @@ export function createSimulationStore(seed = {}) {
break;
}
if (state.taskHalRuntime?.loaded) {
stopTaskHalStatusLoop(action.type === "ABORT" ? "aborted" : "stopped", {
operatorMessage: action.type === "ABORT" ? "task/HAL abort requested" : "task/HAL stop requested",
});
runTaskHalCommandSequence([
{ type: "EMC_TASK_ABORT" },
], { operatorMessage: action.type === "ABORT" ? "task/HAL abort complete" : "task/HAL program stopped" }).catch(() => {});
@@ -1137,6 +1233,7 @@ export function createSimulationStore(seed = {}) {
break;
}
if (state.taskHalRuntime?.loaded) {
stopTaskHalStatusLoop("paused", { operatorMessage: "task/HAL pause requested" });
runTaskHalCommandSequence([
{ type: "EMC_TASK_PLAN_PAUSE" },
], { operatorMessage: "task/HAL program paused" }).catch(() => {});
@@ -1166,7 +1263,15 @@ export function createSimulationStore(seed = {}) {
if (state.taskHalRuntime?.loaded) {
runTaskHalCommandSequence([
{ type: "EMC_TASK_PLAN_RESUME" },
], { operatorMessage: "task/HAL program resumed" }).catch(() => {});
], {
operatorMessage: "task/HAL program resumed",
}).then(() => {
if (state.runState === "running" || state.machine.interpState === "reading") {
startTaskHalStatusLoop({
operatorMessage: "task/HAL status loop resumed",
});
}
}).catch(() => {});
break;
}
const resumeState = state.machine.interpResumeState === "idle"
@@ -1191,6 +1296,14 @@ export function createSimulationStore(seed = {}) {
setState({ operatorMessage: gate.operatorMessage });
break;
}
if (state.taskHalRuntime?.loaded) {
stopTaskHalStatusLoop("step", { operatorMessage: "task/HAL step requested" });
runTaskHalCommandSequence([], {
taskCycles: 1,
operatorMessage: "task/HAL stepped one cycle",
}).catch(() => {});
break;
}
const playback = nextProgramRuntimeSamplePlayback(state, 1);
setState({
machine: {
@@ -1259,6 +1372,11 @@ export function createSimulationStore(seed = {}) {
setState({ operatorMessage: gate.operatorMessage });
break;
}
if (state.taskHalRuntime?.loaded) {
runTaskHalCommandSequence([
{ type: "EMC_JOINT_HOME", joint: -1 },
], { operatorMessage: "task/HAL machine homed" }).catch(() => {});
}
setState({
machine: {
...state.machine,
@@ -1541,6 +1659,13 @@ export function createSimulationStore(seed = {}) {
if (!state.taskHalRuntime?.loaded || !state.machineFileStaging?.save?.files?.length) {
return null;
}
const preserveMachine = {
powerOn: state.machine.powerOn,
estopActive: state.machine.estopActive,
taskState: state.machine.taskState,
mode: state.machine.mode,
allHomed: state.machine.allHomed,
};
const selectedPlan = selectMachineFileProgramForState(state);
const session = buildTaskHalSessionFromMachineFiles({
profile: state.profile,
@@ -1559,12 +1684,28 @@ export function createSimulationStore(seed = {}) {
await state.taskHalRuntime.stageFiles(session.files);
if (openProgram && session.programPath) {
await state.taskHalRuntime.openProgram(session.programPath);
await loadTaskHalMotionPlanForSession(session);
}
if (preserveMachine.powerOn) {
await state.taskHalRuntime.sendCommand({ type: "EMC_TASK_SET_STATE", state: "ON" });
}
if (preserveMachine.allHomed) {
await state.taskHalRuntime.sendCommand({ type: "EMC_JOINT_HOME", joint: -1 });
}
await state.taskHalRuntime.sendCommand({
type: "EMC_TASK_SET_MODE",
mode: normalizeLinuxCncTaskMode(preserveMachine.mode).toUpperCase(),
});
await state.taskHalRuntime.runCycles({
...deriveTaskHalCyclePeriods(state),
taskCycles: 1,
});
dispatch({ type: "TASK_HAL_SESSION_READY", session });
const status = await state.taskHalRuntime.readStatus();
dispatch({
type: "TASK_HAL_STATUS_APPLIED",
status,
preserveMachine,
operatorMessage: `LinuxCNC task/HAL session ready ${session.programPath || "-"}`,
});
return session;
@@ -1581,6 +1722,11 @@ export function createSimulationStore(seed = {}) {
if (!state.taskHalSession || (expectedProgramPath && state.taskHalSession.programPath !== expectedProgramPath)) {
await initializeTaskHalSession({ openProgram: true });
}
const loadedMotionPlan = await loadTaskHalMotionPlanForSession(state.taskHalSession);
if (!loadedMotionPlan) {
setState({ operatorMessage: "run blocked: task/HAL feed motion plan not loaded" });
return null;
}
const ready = validateRunPreconditions(state, { requireTaskHalSession: true });
if (!ready.ok) {
@@ -1588,21 +1734,151 @@ export function createSimulationStore(seed = {}) {
return null;
}
return runTaskHalCommandSequence([
stopTaskHalStatusLoop("restarted", {
operatorMessage: "task/HAL status loop restarting",
});
const status = await runTaskHalCommandSequence([
{ type: "EMC_TASK_SET_STATE", state: "ON" },
{ type: "EMC_TASK_SET_MODE", mode: "AUTO" },
{ type: "EMC_TASK_PLAN_RUN", line: Math.max(Number(state.activeLine || 1) - Number(state.programStartLine || 1), 0) },
{ type: "EMC_TASK_PLAN_RUN", line: 0 },
], {
taskCycles: 5,
operatorMessage: `task/HAL program run ${ready.profileId} ${ready.kinematicsModuleId}`,
allowFixtureSession: false,
});
if (shouldContinueTaskHalStatusLoop(state, status)) {
startTaskHalStatusLoop({
profileId: ready.profileId,
iniPath: ready.iniPath,
kinematicsModuleId: ready.kinematicsModuleId,
operatorMessage: `task/HAL status loop running ${ready.profileId} ${ready.kinematicsModuleId}`,
});
}
return status;
};
const loadTaskHalMotionPlanForSession = async (session = state.taskHalSession) => {
if (!state.taskHalRuntime?.loaded || typeof state.taskHalRuntime.loadProgramMotionPlan !== "function") {
return null;
}
const motion = state.programExecution?.motion || [];
const timing = state.programExecutionTiming || buildTimingForState(state, state.programExecution);
if (!session?.programPath || !Array.isArray(motion) || motion.length === 0 || !Array.isArray(timing?.segments) || timing.segments.length === 0) {
return null;
}
const plan = buildTaskHalProgramMotionPlan({
programPath: session.programPath,
motion,
timing,
programLines: state.programLines,
linearUnits: timing.linearUnits || state.profile?.traj?.linearUnits || "mm",
});
if (plan.segmentCount <= 0) {
return null;
}
await state.taskHalRuntime.loadProgramMotionPlan(plan);
return plan;
};
const startTaskHalStatusLoop = ({
profileId = state.machineProfile,
iniPath = state.profile?.iniPath || null,
kinematicsModuleId = state.profile?.kinematicsModuleId || state.machineProfile,
batchSize = 5,
intervalMs = 25,
taskPeriodNs = deriveTaskHalCyclePeriods(state).taskPeriodNs,
servoPeriodNs = deriveTaskHalCyclePeriods(state).servoPeriodNs,
operatorMessage = "task/HAL status loop running",
} = {}) => {
if (!state.taskHalRuntime?.loaded) return null;
stopTaskHalStatusLoop("restarted", { notify: false });
const sequence = Number(state.taskHalStatusLoop?.sequence || 0) + 1;
dispatch({
type: "TASK_HAL_STATUS_LOOP_STARTED",
sequence,
profileId,
iniPath,
kinematicsModuleId,
batchSize,
intervalMs,
taskPeriodNs,
servoPeriodNs,
operatorMessage,
});
const tick = () => runTaskHalStatusLoopTick(sequence).catch((error) => {
stopTaskHalStatusLoop("error", {
error: error instanceof Error ? error.message : String(error),
operatorMessage: `task/HAL status loop failed: ${error instanceof Error ? error.message : String(error)}`,
});
});
taskHalStatusLoopTimer = setTimeout(tick, intervalMs);
return sequence;
};
const runTaskHalStatusLoopTick = async (sequence) => {
const loop = state.taskHalStatusLoop || {};
if (!loop.active || loop.sequence !== sequence || !state.taskHalRuntime?.loaded) {
return null;
}
await state.taskHalRuntime.runCycles({
taskPeriodNs: loop.taskPeriodNs,
servoPeriodNs: loop.servoPeriodNs,
taskCycles: loop.batchSize,
});
const status = await state.taskHalRuntime.readStatus();
if (state.taskHalStatusLoop?.sequence !== sequence) {
return status;
}
dispatch({
type: "TASK_HAL_STATUS_APPLIED",
status,
loopSequence: sequence,
operatorMessage: `task/HAL status tick ${Number(state.taskHalStatusLoop?.tickCount || 0) + 1}`,
});
if (shouldContinueTaskHalStatusLoop(state, status)) {
taskHalStatusLoopTimer = setTimeout(
() => runTaskHalStatusLoopTick(sequence).catch((error) => {
stopTaskHalStatusLoop("error", {
error: error instanceof Error ? error.message : String(error),
operatorMessage: `task/HAL status loop failed: ${error instanceof Error ? error.message : String(error)}`,
});
}),
Number(state.taskHalStatusLoop?.intervalMs || loop.intervalMs || 25),
);
} else {
stopTaskHalStatusLoop(state.runState === "complete" ? "complete" : state.runState, {
operatorMessage: state.runState === "complete"
? "task/HAL program complete"
: `task/HAL status loop ${state.runState}`,
});
}
return status;
};
const stopTaskHalStatusLoop = (reason = "stopped", {
error = null,
operatorMessage = null,
notify = true,
} = {}) => {
if (taskHalStatusLoopTimer) {
clearTimeout(taskHalStatusLoopTimer);
taskHalStatusLoopTimer = null;
}
if (notify && (state.taskHalStatusLoop?.active || state.taskHalStatusLoop?.stopReason !== reason || error)) {
dispatch({
type: "TASK_HAL_STATUS_LOOP_STOPPED",
reason,
error,
operatorMessage,
});
}
};
const runTaskHalCommandSequence = async (commands, {
taskCycles = 1,
taskPeriodNs = 10000000,
servoPeriodNs = 1000000,
taskPeriodNs = deriveTaskHalCyclePeriods(state).taskPeriodNs,
servoPeriodNs = deriveTaskHalCyclePeriods(state).servoPeriodNs,
operatorMessage = "task/HAL command complete",
pendingJogCommand = null,
allowFixtureSession = true,
@@ -1939,16 +2215,37 @@ function expectedTaskHalProgramPathForState(state = {}) {
}
}
function deriveTaskHalCyclePeriods(state = {}) {
const taskCycleTimeSeconds = Number(state.linuxCncIniConfig?.task?.cycleTimeSeconds);
const iniTaskPeriodNs = Number.isFinite(taskCycleTimeSeconds) && taskCycleTimeSeconds > 0
? Math.round(taskCycleTimeSeconds * 1_000_000_000)
: null;
const iniServoPeriodNs = Number(state.linuxCncIniConfig?.emcmot?.servoPeriodNs);
return {
taskPeriodNs: iniTaskPeriodNs || 10000000,
servoPeriodNs: Number.isFinite(iniServoPeriodNs) && iniServoPeriodNs > 0
? Math.round(iniServoPeriodNs)
: 1000000,
};
}
function normalizeCoordinates(value) {
return String(value || "").replace(/[^A-Za-z]/g, "").toUpperCase();
}
function applyTaskHalStatusPatch(state, status, operatorMessage) {
function applyTaskHalStatusPatch(state, status, operatorMessage, {
loopSequence = null,
preserveMachine = null,
} = {}) {
const ui = status?.ui || {};
const task = status?.task || {};
const motion = status?.motionStatus?.motion || {};
const taskState = normalizeTaskHalTaskState(ui.taskState || task.state);
const taskMode = normalizeLinuxCncTaskMode(ui.taskMode || task.mode || state.machine.mode);
const rawTaskState = normalizeTaskHalTaskState(ui.taskState || task.state);
const taskState = preserveMachine?.powerOn && rawTaskState === "estop-reset"
? "on"
: rawTaskState;
const taskMode = normalizeLinuxCncTaskMode(preserveMachine?.mode || ui.taskMode || task.mode || state.machine.mode);
const interpState = normalizeTaskHalInterpState(ui.interpState || task.interpState);
const activeLine = state.programStartLine + Math.max(Number(ui.activeLine || 1) - 1, 0);
const kinsType = resolveTaskHalKinsType(state, status, activeLine);
@@ -1958,7 +2255,8 @@ function applyTaskHalStatusPatch(state, status, operatorMessage) {
: state.feed.currentVelocity;
const paused = interpState === "paused" || motion.paused === true;
const aborted = motion.aborted === true;
const programComplete = interpState === "idle" && Number(task.nextProgramLine || 0) >= Number(task.openedLineCount || 1);
const openedProgramLineCount = Number(task.openedSourceLineCount || task.openedLineCount || 1);
const programComplete = interpState === "idle" && Number(task.nextProgramLine || 0) >= openedProgramLineCount;
const runState = aborted
? "stopped"
: paused
@@ -1972,6 +2270,13 @@ function applyTaskHalStatusPatch(state, status, operatorMessage) {
: state.runState === "jogging"
? "jogging"
: "idle";
const runtimeFeedback = createTaskHalRuntimeFeedback(state, status, axisPose, activeLine);
const loopActive = state.taskHalStatusLoop?.active === true
&& loopSequence !== null
&& Number(state.taskHalStatusLoop.sequence) === Number(loopSequence)
&& (runState === "running" || runState === "mdi");
const nextTickCount = loopActive ? Number(state.taskHalStatusLoop.tickCount || 0) + 1 : Number(state.taskHalStatusLoop?.tickCount || 0);
const feedbackHistory = [runtimeFeedback, ...(state.programRuntimeFeedbackHistory || [])].slice(0, 100);
return {
taskHalStatus: status,
@@ -1982,9 +2287,7 @@ function applyTaskHalStatusPatch(state, status, operatorMessage) {
axisPose,
kinsType,
rtcpState: rtcpStateFromKinsType(kinsType),
programExecutionSourceMode: state.programExecution
? state.programExecutionSourceMode
: "linuxcnc-task-motion-hal-wasm",
programExecutionSourceMode: "linuxcnc-task-motion-hal-wasm",
machine: {
...state.machine,
powerOn: taskState === "on",
@@ -1994,17 +2297,42 @@ function applyTaskHalStatusPatch(state, status, operatorMessage) {
interpState,
interpResumeState: paused ? state.machine.interpResumeState || "reading" : interpState,
taskPaused: paused,
allHomed: Boolean(preserveMachine?.allHomed ?? state.machine.allHomed),
},
runState,
taskHalStatusLoop: loopSequence === null
? state.taskHalStatusLoop
: {
...state.taskHalStatusLoop,
active: loopActive,
tickCount: nextTickCount,
lastStatusAt: new Date().toISOString(),
stopReason: loopActive ? null : runState,
},
feed: {
...state.feed,
currentVelocity,
},
programRuntimeFeedback: createTaskHalRuntimeFeedback(state, status, axisPose, activeLine),
programRuntimeFeedback: runtimeFeedback,
programRuntimeFeedbackHistory: feedbackHistory,
operatorMessage,
};
}
function shouldContinueTaskHalStatusLoop(state = {}, status = {}) {
const ui = status?.ui || {};
const task = status?.task || {};
const motion = status?.motionStatus?.motion || {};
const interpState = normalizeTaskHalInterpState(ui.interpState || task.interpState);
const taskMode = normalizeLinuxCncTaskMode(ui.taskMode || task.mode || state.machine?.mode);
const aborted = motion.aborted === true;
const paused = interpState === "paused" || motion.paused === true;
const openedProgramLineCount = Number(task.openedSourceLineCount || task.openedLineCount || 1);
const complete = interpState === "idle"
&& Number(task.nextProgramLine || 0) >= openedProgramLineCount;
return !aborted && !paused && !complete && (interpState === "reading" || taskMode === "mdi");
}
function resolveTaskHalKinsType(state, status, activeLine) {
const ui = status?.ui || {};
const numeric = Number(ui.switchkinsType);
@@ -2095,6 +2423,8 @@ function wouldResetNonZeroPoseToLocalZero(currentPose = {}, nextPose = {}) {
function createTaskHalRuntimeFeedback(state, status, axisPose, activeLine) {
const ui = status?.ui || {};
const motion = status?.motionStatus?.motion || {};
const halProgramLine = Number(status?.halSnapshot?.pins?.["motion.program-line"]?.value || 0);
const motionProgramLine = Number(motion.programLine || 0);
return {
apiName: "web-rtcp-5axis-program-runtime-feedback",
sourceMode: "linuxcnc-task-motion-hal-wasm",
@@ -2102,6 +2432,10 @@ function createTaskHalRuntimeFeedback(state, status, axisPose, activeLine) {
sampleIndex: Number(ui.servoCycle || 0),
motionIndex: Math.max(Number(ui.activeLine || 1) - 1, 0),
line: activeLine,
motionProgramLine,
halProgramLine,
activeLineSource: ui.activeLineSource || (motionProgramLine > 0 ? "motion-status" : halProgramLine > 0 ? "hal-pin" : "fallback"),
activeLineHalSynced: motionProgramLine > 0 && halProgramLine > 0 && motionProgramLine === halProgramLine,
type: Number(motion.motionType || 0) === 3 ? "JOG" : "TASK_MOTION",
timeSeconds: Number(ui.taskCycle || 0) * 0.01,
axisPose,
@@ -2452,6 +2786,10 @@ function nextProgramRuntimeSamplePlayback(state, step) {
const sample = samples[sampleIndex];
const motionIndex = clampMotionIndex(state, sample.motionIndex);
const motion = state.programExecution?.motion?.[motionIndex] || null;
const sampleWithUnits = {
...sample,
linearUnits: sample.linearUnits || motion?.linearUnits || state.profile.traj?.linearUnits,
};
const segment = timing?.segments?.[motionIndex] || null;
const kinsType = kinsTypeFromProgramMotion(state, motion) || state.kinsType;
const elapsedSeconds = Number(sample.timeSeconds) || Number(segment?.elapsedSeconds) || 0;
@@ -2461,7 +2799,7 @@ function nextProgramRuntimeSamplePlayback(state, step) {
|| 0;
const runtimeFeedback = createProgramRuntimeFeedbackFromSample({
state,
sample,
sample: sampleWithUnits,
sampleIndex,
motion,
motionIndex,
@@ -2473,7 +2811,7 @@ function nextProgramRuntimeSamplePlayback(state, step) {
motionIndex,
sampleIndex,
activeLine: sample.line || motion?.line || state.activeLine,
axisPose: axisPoseFromRuntimeSample(sample, motion, state.axisPose),
axisPose: axisPoseFromRuntimeSample(sampleWithUnits, motion, state.axisPose),
kinsType,
rtcpState: rtcpStateFromKinsType(kinsType),
timing: {
@@ -2507,9 +2845,13 @@ function nextProgramRuntimeSamplePlayback(state, step) {
function createInitialProgramRuntimeFeedback({ state, timing, motion, timingSnapshot }) {
const firstSample = timing?.samples?.[0] || null;
if (firstSample) {
const sampleWithUnits = {
...firstSample,
linearUnits: firstSample.linearUnits || motion?.linearUnits || state.profile.traj?.linearUnits,
};
return createProgramRuntimeFeedbackFromSample({
state,
sample: firstSample,
sample: sampleWithUnits,
sampleIndex: 0,
motion,
motionIndex: clampMotionIndex(state, firstSample.motionIndex),
@@ -2537,11 +2879,13 @@ function clampMotionIndex(state, motionIndex) {
}
function buildTimingForState(state, execution) {
if (execution?.plannerTiming?.plannerRuntimeReady === true) {
const motion = execution?.motion || [];
const requiresFeedModeTiming = motion.some((event) => event?.feedMode === "inverse-time");
if (!requiresFeedModeTiming && execution?.plannerTiming?.plannerRuntimeReady === true) {
return execution.plannerTiming;
}
return buildProgramExecutionTiming({
motion: execution?.motion || [],
motion,
profile: state.profile,
feedOverride: state.feed.feedOverride,
rapidOverride: state.feed.rapidOverride,
@@ -2713,6 +3057,7 @@ function createProgramRuntimeFeedbackFromSample({
motionIndex,
line: sample?.line || motion?.line || null,
type: sample?.type || motion?.type || null,
linearUnits: sample?.linearUnits || motion?.linearUnits || state.profile.traj?.linearUnits || "mm",
timeSeconds: elapsedSeconds,
axisPose,
currentVelocityMmPerMin: currentVelocity,
@@ -2750,6 +3095,7 @@ function createProgramRuntimeFeedbackFromMotion({
motionIndex,
line: motion?.line || null,
type: motion?.type || null,
linearUnits: motion?.linearUnits || state.profile.traj?.linearUnits || "mm",
timeSeconds: Number(timing?.elapsedSeconds) || 0,
axisPose,
currentVelocityMmPerMin: Number(timing?.currentVelocity) || 0,

View File

@@ -1,4 +1,5 @@
import { renderFiveAxisScene } from "../visualization/five-axis-scene.js";
import { gateLinuxCncTaskAction } from "../state/linuxcnc-task-policy.js";
const REGIONS = [
"titlebar",
@@ -623,7 +624,12 @@ function renderBottomControls(element, state, dispatch) {
element.innerHTML = `
<input type="file" class="program-file-input" data-action="OPEN_FILE" accept=".ngc,.nc,.tap,.gcode,.txt" />
${controls
.map(([label, action]) => `<button type="button" data-action="${action}">${label}</button>`)
.map(([label, action]) => {
const gate = bottomControlGate(state, action);
const disabled = gate.allowed ? "" : " disabled";
const title = gate.allowed ? "" : ` title="${escapeHtml(gate.operatorMessage || "blocked")}"`;
return `<button type="button" data-action="${action}"${disabled}${title}>${label}</button>`;
})
.join("")}
`;
@@ -650,6 +656,22 @@ function renderBottomControls(element, state, dispatch) {
}
}
function bottomControlGate(state, action) {
const actionMap = {
RUN: { type: "RUN" },
STEP: { type: "STEP" },
PAUSE: { type: "PAUSE" },
RESUME: { type: "RESUME" },
HOME: { type: "HOME" },
MDI_RUN: { type: "RUN_MDI" },
JOG_X_NEG: { type: "JOG" },
JOG_X_POS: { type: "JOG" },
JOG_Y_NEG: { type: "JOG" },
JOG_Y_POS: { type: "JOG" },
};
return gateLinuxCncTaskAction(state, actionMap[action] || { type: "UI_CONTROL" });
}
function formatNumber(value, digits = 3) {
return Number(value).toFixed(digits);
}

View File

@@ -1,13 +1,19 @@
import * as THREE from "../vendor/three/three.module.js";
import {
linearUnitsLabel,
linearUnitsToMetersFactor,
linearValueToMeters,
resolveStateLinearUnits,
} from "../runtime/linear-units.js";
const scenes = new WeakMap();
const CAMERA_PRESETS = {
iso: { theta: -0.96, phi: 1.02, radius: 7.0, target: new THREE.Vector3(0, 0, 0) },
x: { theta: 0, phi: Math.PI / 2, radius: 6.2, target: new THREE.Vector3(0, 0, 0) },
y: { theta: -Math.PI / 2, phi: Math.PI / 2, radius: 6.2, target: new THREE.Vector3(0, 0, 0) },
z: { theta: 0, phi: 0.001, radius: 6.6, target: new THREE.Vector3(0, 0, 0) },
iso: { theta: -0.96, phi: 1.02, radius: 0.72, target: new THREE.Vector3(0, 0, 0) },
x: { theta: 0, phi: Math.PI / 2, radius: 0.64, target: new THREE.Vector3(0, 0, 0) },
y: { theta: -Math.PI / 2, phi: Math.PI / 2, radius: 0.64, target: new THREE.Vector3(0, 0, 0) },
z: { theta: 0, phi: 0.001, radius: 0.68, target: new THREE.Vector3(0, 0, 0) },
};
const MAX_TOOLPATH_POINTS = 1600;
const MAX_TOOLPATH_POINTS = Number.POSITIVE_INFINITY;
const EMPTY_GEOMETRY = new THREE.BufferGeometry().setFromPoints([]);
export function renderFiveAxisScene(canvas, state) {
@@ -51,6 +57,11 @@ export function renderFiveAxisScene(canvas, state) {
toolExecutionMarker: preview.toolMarker.visible,
toolAxisMarker: preview.toolAxis.visible,
pathFitBounds: preview.pathFitBoundsReady,
pathBounds: computePointBoundsFromGeometryGroups([
preview.previewPath.geometry,
preview.executedPath.geometry,
preview.currentSegmentPath.geometry,
]),
});
}
@@ -72,7 +83,7 @@ function createScene(canvas) {
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(42, 1, 0.1, 100);
const camera = new THREE.PerspectiveCamera(42, 1, 0.001, 10);
const machineModel = createMachineReferenceModel();
scene.add(machineModel.root);
@@ -84,7 +95,7 @@ function createScene(canvas) {
const arcPath = createLine(0xd7ff62, 0.95);
const currentSegmentPath = createLine(0xff4fd8, 1);
const toolMarker = new THREE.Mesh(
new THREE.SphereGeometry(0.065, 18, 12),
new THREE.SphereGeometry(0.0065, 18, 12),
new THREE.MeshBasicMaterial({ color: 0x1ffff4 }),
);
const toolAxis = new THREE.Line(
@@ -165,6 +176,7 @@ function renderFallbackPreview(preview, state) {
arcPointCount: arcPoints.length,
currentSegmentPointCount: currentSegmentPoints.length,
pathFitBounds: computePointBounds(previewPoints.concat(executedPoints, currentSegmentPoints)) !== null,
pathBounds: summarizeBounds(computePointBounds(previewPoints.concat(executedPoints, currentSegmentPoints))),
});
canvas.dataset.threeFallbackReason = preview.errorMessage;
@@ -177,7 +189,7 @@ function renderFallbackPreview(preview, state) {
const cx = width * 0.5;
const cy = height * 0.53;
const scale = Math.min(width / 7.2, height / 4.8);
const scale = Math.min(width / 0.72, height / 0.48);
drawFallbackMachineReference(ctx, cx, cy, scale, state);
@@ -225,6 +237,9 @@ function exposePreviewDataset(canvas, state, preview) {
canvas.dataset.threeExecutedPathPoints = String(preview.executedPointCount);
canvas.dataset.threeSceneObjects = String(preview.sceneObjectCount);
canvas.dataset.threeToolhead = JSON.stringify(toRoundedVector(preview.toolhead));
canvas.dataset.threeSceneUnits = "m";
canvas.dataset.threeLinearUnits = linearUnitsLabel(resolveSceneLinearUnits(state));
canvas.dataset.threeLinearUnitScaleToMeters = String(linearUnitsToMetersFactor(resolveSceneLinearUnits(state)));
canvas.dataset.threeToolAxis = JSON.stringify(toRoundedVector(state.toolAxisVector));
canvas.dataset.threeTcpPose = JSON.stringify(toRoundedPose(state.tcpPose));
canvas.dataset.threeRtcpState = state.rtcpState;
@@ -244,6 +259,7 @@ function exposePreviewDataset(canvas, state, preview) {
canvas.dataset.threeToolpathPreviewSource = toolpathPreviewSource(state);
canvas.dataset.threeToolExecutionTraceSource = toolExecutionTraceSource(state);
canvas.dataset.threePathFitBounds = preview.pathFitBounds ? "ok" : "pending";
canvas.dataset.threePathBoundsMeters = JSON.stringify(preview.pathBounds || null);
canvas.dataset.threeCurrentSegmentHighlight = preview.currentSegmentPointCount > 0 ? "ok" : "pending";
canvas.dataset.threeRapidFeedVisualDistinction = preview.rapidPointCount > 0 || preview.feedPointCount > 0 || preview.arcPointCount > 0 ? "ok" : "pending";
canvas.dataset.threeNoGcodeSemanticsGeneration = "ok";
@@ -270,47 +286,47 @@ function createMachineReferenceModel() {
root.name = "five-axis-machine-reference";
const base = new THREE.Mesh(
new THREE.BoxGeometry(4.8, 3.2, 0.08),
new THREE.BoxGeometry(0.48, 0.32, 0.008),
new THREE.MeshBasicMaterial({ color: 0x222930 }),
);
base.position.z = -0.16;
base.position.z = -0.016;
const table = new THREE.Mesh(
new THREE.BoxGeometry(3.7, 2.35, 0.05),
new THREE.BoxGeometry(0.37, 0.235, 0.005),
new THREE.MeshBasicMaterial({ color: 0x3a444d, transparent: true, opacity: 0.78 }),
);
table.position.z = -0.08;
table.position.z = -0.008;
const xAxis = createStaticLine([new THREE.Vector3(-2.2, 0, 0), new THREE.Vector3(2.25, 0, 0)], 0xff4d4d);
const yAxis = createStaticLine([new THREE.Vector3(0, -1.55, 0), new THREE.Vector3(0, 1.6, 0)], 0x70df7d);
const zAxis = createStaticLine([new THREE.Vector3(0, 0, -0.08), new THREE.Vector3(0, 0, 1.75)], 0x5aa7ff);
const xAxis = createStaticLine([new THREE.Vector3(-0.22, 0, 0), new THREE.Vector3(0.225, 0, 0)], 0xff4d4d);
const yAxis = createStaticLine([new THREE.Vector3(0, -0.155, 0), new THREE.Vector3(0, 0.16, 0)], 0x70df7d);
const zAxis = createStaticLine([new THREE.Vector3(0, 0, -0.008), new THREE.Vector3(0, 0, 0.175)], 0x5aa7ff);
const rotaryA = new THREE.Mesh(
new THREE.TorusGeometry(0.88, 0.018, 8, 72),
new THREE.TorusGeometry(0.088, 0.0018, 8, 72),
new THREE.MeshBasicMaterial({ color: 0x1ffff4, transparent: true, opacity: 0.92 }),
);
rotaryA.rotation.y = Math.PI / 2;
const rotaryC = new THREE.Mesh(
new THREE.TorusGeometry(1.1, 0.016, 8, 72),
new THREE.TorusGeometry(0.11, 0.0016, 8, 72),
new THREE.MeshBasicMaterial({ color: 0xffd166, transparent: true, opacity: 0.9 }),
);
rotaryC.rotation.x = Math.PI / 2;
rotaryC.position.z = 0.04;
rotaryC.position.z = 0.004;
const toolHolder = new THREE.Group();
const holderBody = new THREE.Mesh(
new THREE.CylinderGeometry(0.08, 0.08, 0.42, 18),
new THREE.CylinderGeometry(0.008, 0.008, 0.042, 18),
new THREE.MeshBasicMaterial({ color: 0xf1f5f9 }),
);
holderBody.rotation.x = Math.PI / 2;
holderBody.position.z = 0.32;
holderBody.position.z = 0.032;
const cutter = new THREE.Mesh(
new THREE.ConeGeometry(0.06, 0.25, 18),
new THREE.ConeGeometry(0.006, 0.025, 18),
new THREE.MeshBasicMaterial({ color: 0xfff176 }),
);
cutter.rotation.x = Math.PI;
cutter.position.z = 0.08;
cutter.position.z = 0.008;
toolHolder.add(holderBody, cutter);
root.add(base, table, xAxis, yAxis, zAxis, rotaryA, rotaryC, toolHolder);
@@ -387,7 +403,7 @@ function updateToolExecutionMarker(preview, state, toolPosition) {
preview.toolAxis.visible = true;
updateLineGeometry(preview.toolAxis, [
toolPosition,
toolPosition.clone().add(vector.multiplyScalar(0.7)),
toolPosition.clone().add(vector.multiplyScalar(0.07)),
]);
}
@@ -401,7 +417,7 @@ function updateMachineReferenceModel(preview, state, toolPosition) {
model.rotaryA.rotation.y = Math.PI / 2 + b;
model.rotaryC.rotation.z = c;
const tcpPosition = toolPosition || toPreviewVector(state.tcpPose || state.axisPose);
const tcpPosition = toolPosition || toPreviewVector(state.tcpPose || state.axisPose, state);
model.toolHolder.position.copy(tcpPosition);
const toolVector = toToolVector(state.toolAxisVector);
model.toolHolder.lookAt(tcpPosition.clone().add(toolVector));
@@ -422,12 +438,12 @@ function geometryPointCount(geometry) {
function buildProgramPreviewPoints(state) {
const motion = state.programExecution?.motion;
if (Array.isArray(motion) && motion.length > 0 && state.preview.pathPoints !== 0) {
return limitPoints(motion.map((event) => vectorFromAxes(event.axes)));
return limitPoints(motion.map((event) => vectorFromAxes(event.axes, state, event.linearUnits)));
}
const pointCount = normalizePathPointCount(state.preview.pathPoints);
if (pointCount === 0) return [];
return buildFixturePreviewPoints(pointCount, toPreviewVector(state.tcpPose));
return buildFixturePreviewPoints(pointCount, toPreviewVector(state.tcpPose, state));
}
function buildExecutedProgramPoints(state, previewPoints) {
@@ -436,7 +452,7 @@ function buildExecutedProgramPoints(state, previewPoints) {
const sampleIndex = Number(state.programExecutionSampleIndex || 0);
if (Array.isArray(samples) && samples.length > 0) {
const end = clamp(Math.round(sampleIndex), 0, samples.length - 1);
return limitPoints(samples.slice(0, end + 1).map((sample) => vectorFromAxes(sample)));
return limitPoints(samples.slice(0, end + 1).map((sample) => vectorFromAxes(sample, state, sample.linearUnits)));
}
const motionIndex = clamp(Math.round(Number(state.programExecutionMotionIndex || 0)), 0, previewPoints.length - 1);
@@ -453,7 +469,7 @@ function buildTypedPreviewPoints(state, type) {
return limitPoints(
motion
.filter((event) => event.type === type)
.map((event) => vectorFromAxes(event.axes)),
.map((event) => vectorFromAxes(event.axes, state, event.linearUnits)),
);
}
@@ -465,8 +481,10 @@ function buildCurrentSegmentPoints(state) {
const current = motion[motionIndex];
const previous = motion[Math.max(motionIndex - 1, 0)];
if (!current) return [];
const start = motionIndex === 0 ? vectorFromAxes(previous?.axes || current.axes) : vectorFromAxes(previous.axes);
const end = vectorFromAxes(current.axes);
const start = motionIndex === 0
? vectorFromAxes(previous?.axes || current.axes, state, previous?.linearUnits || current.linearUnits)
: vectorFromAxes(previous.axes, state, previous.linearUnits);
const end = vectorFromAxes(current.axes, state, current.linearUnits);
return start.distanceTo(end) > 0 ? [start, end] : [end];
}
@@ -474,9 +492,9 @@ function buildFixturePreviewPoints(pointCount, tcpPosition) {
const points = [];
for (let index = 0; index < pointCount; index += 1) {
const t = pointCount === 1 ? 0 : index / (pointCount - 1);
const x = -2.45 + t * 4.9;
const y = Math.sin(t * Math.PI * 13) * 0.36;
const z = -0.68 + Math.sin(t * Math.PI * 2) * 0.42;
const x = -0.085 + t * 0.17;
const y = Math.sin(t * Math.PI * 13) * 0.012;
const z = 0.012 + Math.sin(t * Math.PI * 2) * 0.018;
points.push(new THREE.Vector3(x, y, z));
}
if (points.length > 0 && tcpPosition) {
@@ -487,33 +505,42 @@ function buildFixturePreviewPoints(pointCount, tcpPosition) {
function executionToolPosition(state, previewPoints) {
const feedbackAxes = state.programRuntimeFeedback?.axisPose || state.programRuntimeFeedback;
if (feedbackAxes && hasLinearAxes(feedbackAxes)) return vectorFromAxes(feedbackAxes);
if (hasLinearAxes(state.axisPose)) return vectorFromAxes(state.axisPose);
if (feedbackAxes && hasLinearAxes(feedbackAxes)) {
return vectorFromAxes(feedbackAxes, state, state.programRuntimeFeedback?.linearUnits);
}
if (hasLinearAxes(state.axisPose)) return vectorFromAxes(state.axisPose, state);
return previewPoints.at(-1) || null;
}
function vectorFromAxes(axes = {}) {
export function axesToSceneMeters(axes = {}, state = {}, linearUnits = null) {
const units = linearUnits || axes.linearUnits || resolveSceneLinearUnits(state);
return {
x: linearValueToMeters(axes.x, units),
y: linearValueToMeters(axes.y, units),
z: linearValueToMeters(axes.z, units),
};
}
function vectorFromAxes(axes = {}, state = {}, linearUnits = null) {
const point = axesToSceneMeters(axes, state, linearUnits);
return new THREE.Vector3(
scaleLinearAxis(axes.x),
scaleLinearAxis(axes.y),
scaleZAxis(axes.z),
point.x,
point.y,
point.z,
);
}
function toPreviewVector(pose = {}) {
function toPreviewVector(pose = {}, state = {}) {
const point = axesToSceneMeters(pose, state);
return new THREE.Vector3(
scaleLinearAxis(pose.x),
scaleLinearAxis(pose.y),
scaleZAxis(pose.z),
point.x,
point.y,
point.z,
);
}
function scaleLinearAxis(value) {
return clamp((Number(value) || 0) * 0.035, -2.7, 2.7);
}
function scaleZAxis(value) {
return clamp((Number(value) || 0) * 0.04 + 0.35, -1.1, 1.9);
function resolveSceneLinearUnits(state) {
return resolveStateLinearUnits(state);
}
function toToolVector(vector = {}) {
@@ -596,6 +623,35 @@ function computePointBounds(points) {
return box;
}
function computePointBoundsFromGeometryGroups(geometries) {
const points = [];
for (const geometry of geometries) {
const position = geometry?.getAttribute("position");
if (!position) continue;
for (let index = 0; index < position.count; index += 1) {
points.push(new THREE.Vector3(
position.getX(index),
position.getY(index),
position.getZ(index),
));
}
}
return summarizeBounds(computePointBounds(points));
}
function summarizeBounds(bounds) {
if (!bounds) return null;
const center = new THREE.Vector3();
const size = new THREE.Vector3();
bounds.getCenter(center);
bounds.getSize(size);
return {
center: toRoundedVector(center),
size: toRoundedVector(size),
maxSpan: Number(Math.max(size.x, size.y, size.z).toFixed(6)),
};
}
function drawFallbackPolyline(ctx, points, cx, cy, scale) {
for (let index = 0; index < points.length; index += 1) {
const point = points[index];
@@ -607,27 +663,27 @@ function drawFallbackPolyline(ctx, points, cx, cy, scale) {
}
function drawFallbackMachineReference(ctx, cx, cy, scale, state) {
const tableWidth = 4.8 * scale;
const tableHeight = 3.2 * scale;
const tableWidth = 0.48 * scale;
const tableHeight = 0.32 * scale;
ctx.fillStyle = "#20272e";
ctx.strokeStyle = "#56616b";
ctx.lineWidth = 2;
ctx.fillRect(cx - tableWidth / 2, cy - tableHeight / 2, tableWidth, tableHeight);
ctx.strokeRect(cx - tableWidth / 2, cy - tableHeight / 2, tableWidth, tableHeight);
drawFallbackAxis(ctx, cx - 2.25 * scale, cy, cx + 2.25 * scale, cy, "#ff4d4d");
drawFallbackAxis(ctx, cx, cy + 1.55 * scale, cx, cy - 1.6 * scale, "#70df7d");
drawFallbackAxis(ctx, cx, cy + 0.2 * scale, cx, cy - 1.15 * scale, "#5aa7ff");
drawFallbackAxis(ctx, cx - 0.225 * scale, cy, cx + 0.225 * scale, cy, "#ff4d4d");
drawFallbackAxis(ctx, cx, cy + 0.155 * scale, cx, cy - 0.16 * scale, "#70df7d");
drawFallbackAxis(ctx, cx, cy + 0.02 * scale, cx, cy - 0.115 * scale, "#5aa7ff");
ctx.strokeStyle = "#1ffff4";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.ellipse(cx, cy, 0.92 * scale, 0.42 * scale, degreesToRadians(state.axisPose?.a), 0, Math.PI * 2);
ctx.ellipse(cx, cy, 0.092 * scale, 0.042 * scale, degreesToRadians(state.axisPose?.a), 0, Math.PI * 2);
ctx.stroke();
ctx.strokeStyle = "#ffd166";
ctx.beginPath();
ctx.arc(cx, cy, 0.7 * scale, 0, Math.PI * 2);
ctx.arc(cx, cy, 0.07 * scale, 0, Math.PI * 2);
ctx.stroke();
const tcp = executionToolPosition(state, []);
@@ -637,12 +693,12 @@ function drawFallbackMachineReference(ctx, cx, cy, scale, state) {
ctx.strokeStyle = "#f1f5f9";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(toolX, toolY - 0.38 * scale);
ctx.lineTo(toolX, toolY - 0.08 * scale);
ctx.moveTo(toolX, toolY - 0.038 * scale);
ctx.lineTo(toolX, toolY - 0.008 * scale);
ctx.stroke();
ctx.fillStyle = "#1ffff4";
ctx.beginPath();
ctx.arc(toolX, toolY, 0.07 * scale, 0, Math.PI * 2);
ctx.arc(toolX, toolY, 0.007 * scale, 0, Math.PI * 2);
ctx.fill();
}
}
@@ -676,7 +732,7 @@ function createToolpathCameraControls(canvas, camera, renderFrame) {
canvas.addEventListener("wheel", (event) => {
event.preventDefault();
const scale = Math.exp(Math.sign(event.deltaY) * 0.12);
controls.radius = clamp(controls.radius * scale, 1.2, 28);
controls.radius = clamp(controls.radius * scale, 0.06, 4);
applyCameraControls(controls);
controls.renderFrame();
}, { passive: false });
@@ -700,7 +756,7 @@ function createToolpathCameraControls(canvas, camera, renderFrame) {
const distance = getPointerDistance(controls.pointers);
const center = getPointerCenter(controls.pointers);
if (distance > 0 && controls.lastPinchDistance > 0) {
controls.radius = clamp(controls.radius * (controls.lastPinchDistance / distance), 1.2, 28);
controls.radius = clamp(controls.radius * (controls.lastPinchDistance / distance), 0.06, 4);
if (controls.lastPinchCenter) {
panCamera(
controls,
@@ -810,9 +866,9 @@ function applyFitBounds(controls, selectedView, fitPoints) {
bounds.getCenter(center);
bounds.getSize(size);
controls.target.copy(center);
const maxSpan = Math.max(size.x, size.y, size.z, 0.8);
const fitRadius = clamp(maxSpan * 1.8, 2.2, 28);
controls.radius = selectedView === "z" ? Math.max(fitRadius, 4.2) : fitRadius;
const maxSpan = Math.max(size.x, size.y, size.z, 0.08);
const fitRadius = clamp(maxSpan * 1.8, 0.22, 4);
controls.radius = selectedView === "z" ? Math.max(fitRadius, 0.42) : fitRadius;
return true;
}