Files
KDL_WORK/kdl-wasm/web/app/virtual-controller.js
2026-06-28 20:58:18 +08:00

1804 lines
62 KiB
JavaScript

(function () {
const root = document.querySelector(".workbench");
const fields = new Map(
Array.from(document.querySelectorAll("[data-field]")).map((item) => [item.dataset.field, item])
);
const tabButtons = Array.from(document.querySelectorAll("[data-tab]"));
const programSelect = document.querySelector("[data-program-select]");
const frameSlider = document.querySelector("[data-frame-slider]");
const frameTable = document.querySelector("[data-frame-table]");
const stepTable = document.querySelector("[data-step-table]");
const objectTree = document.querySelector("[data-object-tree]");
const pathCurrent = document.querySelector("[data-path-current]");
const parserProgress = document.querySelector("[data-parser-progress]");
const fallbackManifest = {
release_id: "local",
suite_id: "working2-abb120-spec",
job_id: "W2-JOB-latest",
robot_id: "abb_irb120_3_58",
report_html: "test-results/abb120-spec/W2-JOB-latest/report.html",
programs: [
{
program_id: "W2_60_IOWaitPulse",
file: "runtime/W2_60_IOWaitPulse.grl",
source: "",
coverage_level: "Runtime",
status: "pass",
spec_sections: [13],
diagnostics: [],
artifacts: {}
}
]
};
const fallbackProgramData = {
sourceText: [
"language grl 0.1",
"module W2IOWaitPulse",
" const speed v = joint(35 %)",
" const zone zf = fine",
" target home = joint_target { joints: [0 deg, 0 deg, 0 deg, 0 deg, 0 deg, 0 deg] }",
" path io_path {",
" defaults { speed: v, zone: zf }",
" point p0 movej home",
" event at p0 distance 5 + 5 mm pulse io.do[20] duration 50 + 50 ms",
" }",
" proc main()",
" io.do[1] = true",
" io.ao[1] = 0.75",
" io.go[1] = 3",
" wait all(io.di[1] == true, io.di[2] == false) timeout 1 s on_timeout alarm \"all timeout\"",
" wait any(io.di[3] == true, io.di[4] == true) timeout 1 s",
" wait rising(io.di[5]) timeout 1 s",
" wait falling(io.di[6]) timeout 1 s",
" wait changed(io.di[7]) timeout 1 s",
" pulse io.do[2] duration 50 + 50 ms",
" run_path io_path",
" end",
"end"
].join("\n"),
trajectory: {
sampleCount: 4,
duration: 0.24,
startJoints: [0, 0, 0, 0, 0, 0],
endJoints: [0.12, 0.03, -0.08, 0, 0.02, 0],
frames: [
frame(0, 0, "MOVEJ", "home", [0, 0, 0, 0, 0, 0], [0.374, 0, 0.63], { line: 8, column: 14 }),
frame(1, 0.08, "MOVEJ", "home", [0.04, 0.01, -0.026667, 0, 0.006667, 0], [0.377333, 0, 0.63], { line: 8, column: 14 }),
frame(2, 0.16, "MOVEJ", "home", [0.08, 0.02, -0.053333, 0, 0.013333, 0], [0.380667, 0, 0.63], { line: 8, column: 14 }),
frame(3, 0.24, "MOVEJ", "home", [0.12, 0.03, -0.08, 0, 0.02, 0], [0.384, 0, 0.63], { line: 8, column: 14 })
],
diagnostics: []
},
controller: {
commands: [],
snapshot: {
runtime: {
pc: 0,
trace: []
},
state: {
state: "stopped",
diagnostics: []
}
}
},
motionQueue: {
items: [
{
id: "mq_local",
source: { kind: "path", pathId: "io_path", sourceMap: { line: 21, column: 5 } },
request: {
pathId: "io_path",
segments: [{ id: "p0", motion: "MOVEJ", targetId: "home", sourceMap: { line: 8, column: 14 } }],
events: [{ id: "event_0", timing: "at", pointId: "p0", kind: "pulse", sourceMap: { line: 9, column: 35 } }]
}
}
]
},
trace: [
traceEntry(0, "io", "IO_WRITE", 12, 0),
traceEntry(0, "io", "IO_WRITE", 13, 1),
traceEntry(0, "io", "IO_WRITE", 14, 2),
traceEntry(0, "wait", "WAIT", 15, 3, { status: "satisfied" }),
traceEntry(0, "wait", "WAIT", 16, 4, { status: "satisfied" }),
traceEntry(0.1, "wait", "WAIT", 17, 5, { status: "satisfied" }),
traceEntry(0.1, "wait", "WAIT", 18, 6, { status: "satisfied" }),
traceEntry(0.1, "wait", "WAIT", 19, 7, { status: "satisfied" }),
traceEntry(0.1, "io", "PULSE", 20, 8),
traceEntry(0.1, "motion", "RUN_PATH", 21, 9, { queued: true })
],
io: { image: { "io.di[1]": false, "io.do[1]": false }, events: [] },
compile: { moduleName: "W2IOWaitPulse", pathCount: 1, operationCount: 0 },
postReport: { filenames: ["W2IOWaitPulse.mod", "W2IOWaitPulse.ls", "W2IOWaitPulse.src"], report: [] },
roundtrip: {},
isFallback: true,
releaseId: "local",
errors: []
};
const app = {
manifest: fallbackManifest,
programs: fallbackManifest.programs,
selectedProgramId: "W2_60_IOWaitPulse",
programCache: new Map([["W2_60_IOWaitPulse", fallbackProgramData]]),
command: "ready",
mode: "auto",
motors: "on",
tab: "grl",
currentFrameIndex: 0,
currentSampleIndex: 0,
playbackTime: 0,
playbackStartedAt: 0,
playbackTimeStart: 0,
playbackSampleIndex: 0,
samplePeriodSec: 0.05,
samplePeriodMs: 50,
di1: false,
do1: false,
wait: "none",
report: "W2-REPORT working2-abb120-spec",
localTrace: ["W2-JOB-latest ready"],
playbackTimer: 0,
programLoads: new Map(),
assetTextCache: new Map(),
assetRefreshes: new Set(),
parseStream: {
active: false,
timer: 0,
programId: "",
cursorLine: 0,
totalLines: 0,
linesPerTick: 1,
startedAt: 0,
completedProgramId: "",
lastSummary: "idle"
},
storage: {
mode: "checking",
hits: 0,
network: 0,
writes: 0,
errors: 0,
last: "checking browser storage"
}
};
window.__virtualController = app;
window.__vcErrors = [];
function frame(index, time, motion, targetId, joints, position, sourceMap) {
return {
index,
time,
motion,
targetId,
joints,
tcp: {
position,
quaternion: [0, 0.707107, 0, 0.707107]
},
sourceMap,
diagnostics: []
};
}
function traceEntry(time, kind, instructionKind, line, pc, data) {
return {
time,
kind,
procedure: "main",
pc,
instructionKind,
source: {
kind: instructionKind,
id: `main:${instructionKind}:${line}:5`,
sourceMap: { line, column: 5 },
procedureId: "main"
},
data,
id: pc + 1
};
}
function selectedProgram() {
return app.programs.find((program) => program.program_id === app.selectedProgramId) ?? app.programs[0];
}
function cachedProgramData(programId = app.selectedProgramId) {
return app.programCache.get(programId) ?? fallbackProgramData;
}
function selectedData() {
const data = cachedProgramData();
if (app.parseStream.active && app.parseStream.programId === app.selectedProgramId) {
return projectedProgramData(data, app.parseStream.cursorLine);
}
return data;
}
function projectedProgramData(data, cursorLine) {
const lines = (data.sourceText || fallbackProgramData.sourceText).split(/\r?\n/);
const visibleLineCount = Math.min(Math.max(Math.round(numberOr(cursorLine, 0)), 0), lines.length);
const sourceText = [
...lines.slice(0, visibleLineCount),
...(visibleLineCount < lines.length ? [`... parsing ${visibleLineCount}/${lines.length}`] : [])
].join("\n");
return {
...data,
sourceText,
trajectory: projectedTrajectory(data.trajectory, visibleLineCount),
controller: projectedController(data.controller, visibleLineCount),
motionQueue: projectedMotionQueue(data.motionQueue, visibleLineCount),
trace: projectedTrace(data.trace, visibleLineCount),
postReport: visibleLineCount >= lines.length ? data.postReport : { filenames: data.postReport?.filenames ?? [], report: [] },
isStreaming: true
};
}
function projectedTrajectory(trajectory, cursorLine) {
const sourceFrames = Array.isArray(trajectory?.frames) ? trajectory.frames : [];
const visibleFrames = sourceFrames.filter((item) => numberOr(item?.sourceMap?.line, 0) <= cursorLine);
const lastTime = numberOr(visibleFrames.at(-1)?.time, 0);
return {
sampleCount: visibleFrames.length,
duration: lastTime,
frames: visibleFrames,
diagnostics: Array.isArray(trajectory?.diagnostics)
? trajectory.diagnostics.filter((item) => numberOr(item?.sourceMap?.line, 0) <= cursorLine)
: []
};
}
function projectedController(controller, cursorLine) {
const trace = Array.isArray(controller?.snapshot?.runtime?.trace)
? controller.snapshot.runtime.trace.filter((item) => numberOr(item?.source?.sourceMap?.line, 0) <= cursorLine)
: [];
return {
...controller,
snapshot: {
...(controller?.snapshot ?? {}),
runtime: {
...(controller?.snapshot?.runtime ?? {}),
trace
}
}
};
}
function projectedTrace(trace, cursorLine) {
return Array.isArray(trace)
? trace.filter((item) => numberOr(item?.source?.sourceMap?.line, 0) <= cursorLine)
: [];
}
function projectedMotionQueue(motionQueue, cursorLine) {
const sourceItems = Array.isArray(motionQueue?.items) ? motionQueue.items : [];
const items = [];
for (const item of sourceItems) {
const parentLine = numberOr(item?.source?.sourceMap?.line ?? item?.source?.instruction?.sourceMap?.line, 0);
const request = item.request ?? {};
const segments = Array.isArray(request.segments)
? request.segments.filter((segment) => numberOr(segment?.sourceMap?.line, 0) <= cursorLine)
: [];
const events = Array.isArray(request.events)
? request.events.filter((event) => numberOr(event?.sourceMap?.line, 0) <= cursorLine)
: [];
if (parentLine > cursorLine && segments.length === 0 && events.length === 0) continue;
items.push({
...item,
request: {
...request,
segments,
events
}
});
}
return {
...motionQueue,
items
};
}
function frames() {
const data = selectedData();
return normalizeFrames(data.trajectory, data.isFallback === true && data.isStreaming !== true);
}
function trajectoryDuration(items = frames()) {
const duration = Number(selectedData().trajectory?.duration);
const lastTime = Number(items.at(-1)?.time ?? 0);
const trace = selectedData().trace;
const traceTime = Array.isArray(trace)
? trace.reduce((max, item) => Math.max(max, numberOr(item?.time, 0)), 0)
: 0;
return Math.max(
0,
Number.isFinite(duration) ? duration : 0,
Number.isFinite(lastTime) ? lastTime : 0,
traceTime
);
}
function executionSamples() {
const items = frames();
const duration = trajectoryDuration(items);
const step = app.samplePeriodSec;
const times = [];
if (duration <= 0) {
times.push(0);
} else {
for (let time = 0; time < duration - 1e-9; time += step) {
times.push(Number(time.toFixed(6)));
}
if (times.length === 0 || Math.abs(times.at(-1) - duration) > 1e-9) {
times.push(Number(duration.toFixed(6)));
}
}
return times.map((time, index) => interpolateSample(items, time, index));
}
function hasTrajectoryData(data) {
const trajectory = data?.trajectory;
return Array.isArray(trajectory?.frames) && trajectory.frames.length > 0
|| Array.isArray(trajectory?.startJoints)
|| Array.isArray(trajectory?.endJoints);
}
function currentFrame() {
const samples = executionSamples();
return samples[Math.min(app.currentSampleIndex, Math.max(samples.length - 1, 0))]
?? fallbackProgramData.trajectory.frames[0];
}
function currentSamples() {
return executionSamples();
}
function syncFrameIndexFromTime(time = app.playbackTime) {
const items = frames();
if (items.length === 0) {
app.currentFrameIndex = 0;
return;
}
let index = 0;
for (let i = 0; i < items.length; i += 1) {
if (items[i].time <= time + 1e-9) index = i;
else break;
}
app.currentFrameIndex = index;
}
function setSampleIndex(index) {
const samples = currentSamples();
const total = samples.length;
app.currentSampleIndex = Math.min(Math.max(Math.round(numberOr(index, 0)), 0), Math.max(total - 1, 0));
app.playbackTime = samples[app.currentSampleIndex]?.time ?? 0;
syncFrameIndexFromTime(app.playbackTime);
}
function setPlaybackTime(time) {
const samples = currentSamples();
if (samples.length === 0) {
app.currentSampleIndex = 0;
app.playbackTime = 0;
syncFrameIndexFromTime(0);
return;
}
const duration = samples.at(-1).time;
const clamped = Math.min(Math.max(numberOr(time, 0), 0), duration);
const sampleIndex = Math.min(Math.round(clamped / app.samplePeriodSec), samples.length - 1);
app.currentSampleIndex = sampleIndex;
app.playbackTime = samples[sampleIndex]?.time ?? clamped;
syncFrameIndexFromTime(app.playbackTime);
}
function resetExecutionClock() {
app.currentSampleIndex = 0;
app.currentFrameIndex = 0;
app.playbackTime = 0;
app.playbackStartedAt = 0;
app.playbackTimeStart = 0;
app.playbackSampleIndex = 0;
}
function syncExecutionClockToLatestSample() {
const samples = currentSamples();
app.currentSampleIndex = Math.max(samples.length - 1, 0);
app.playbackTime = samples.at(-1)?.time ?? 0;
syncFrameIndexFromTime(app.playbackTime);
}
function interpolateSample(items, time, sampleIndex) {
if (!Array.isArray(items) || items.length === 0) {
return frame(sampleIndex, time, "NO_MOTION", "no planned segment", [0, 0, 0, 0, 0, 0], [0, 0, 0]);
}
const first = items[0];
if (time <= first.time) return sampleFromFrame(first, time, sampleIndex);
const last = items.at(-1);
if (time >= last.time) return sampleFromFrame(last, time, sampleIndex);
let nextIndex = items.findIndex((item) => item.time >= time);
if (nextIndex <= 0) nextIndex = 1;
const previous = items[nextIndex - 1];
const next = items[nextIndex];
const span = Math.max(next.time - previous.time, 1e-9);
const ratio = Math.min(Math.max((time - previous.time) / span, 0), 1);
const source = ratio < 1 ? previous : next;
return {
index: sampleIndex,
time,
motion: source.motion || next.motion || previous.motion || "MOVEJ",
segmentId: source.segmentId ?? next.segmentId ?? previous.segmentId,
targetId: source.targetId ?? next.targetId ?? previous.targetId,
joints: interpolateArray(previous.joints, next.joints, ratio),
tcp: {
position: interpolateArray(previous.tcp.position, next.tcp.position, ratio),
quaternion: normalizeQuaternion(interpolateArray(previous.tcp.quaternion, next.tcp.quaternion, ratio))
},
sourceMap: source.sourceMap,
trajectoryFrameIndex: source.index,
diagnostics: [...(previous.diagnostics ?? []), ...(next.diagnostics ?? [])]
};
}
function sampleFromFrame(item, time, sampleIndex) {
return {
...item,
index: sampleIndex,
time,
joints: [...item.joints],
tcp: {
position: [...item.tcp.position],
quaternion: [...item.tcp.quaternion]
},
trajectoryFrameIndex: item.index
};
}
function interpolateArray(start, end, ratio) {
const a = Array.isArray(start) ? start : [];
const b = Array.isArray(end) ? end : [];
return Array.from({ length: Math.max(a.length, b.length) }, (_item, index) => {
const left = numberOr(a[index], 0);
const right = numberOr(b[index], left);
return left + (right - left) * ratio;
});
}
function normalizeQuaternion(values) {
const q = padNumbers(values, 4, [0, 0.707107, 0, 0.707107]);
const length = Math.hypot(q[0], q[1], q[2], q[3]);
if (length <= 1e-9) return [0, 0.707107, 0, 0.707107];
return q.map((value) => value / length);
}
function normalizeFrames(trajectory, allowFallback) {
if (Array.isArray(trajectory?.frames) && trajectory.frames.length > 0) {
return trajectory.frames.map((item, index) => ({
index: numberOr(item.index, index),
time: numberOr(item.time, index * 0.02),
motion: item.motion || "MOVEJ",
segmentId: item.segmentId,
targetId: item.targetId,
joints: padNumbers(item.joints, 6),
tcp: {
position: padNumbers(item.tcp?.position, 3),
quaternion: padNumbers(item.tcp?.quaternion, 4, [0, 0.707107, 0, 0.707107])
},
sourceMap: item.sourceMap,
diagnostics: Array.isArray(item.diagnostics) ? item.diagnostics : []
}));
}
if (Array.isArray(trajectory?.startJoints) || Array.isArray(trajectory?.endJoints)) {
return [
frame(0, 0, "MOVEJ", "start", padNumbers(trajectory.startJoints, 6), [0.374, 0, 0.63]),
frame(1, numberOr(trajectory.duration, 0), "MOVEJ", "end", padNumbers(trajectory.endJoints, 6), [0.384, 0, 0.63])
];
}
if (allowFallback) return fallbackProgramData.trajectory.frames;
return [frame(0, 0, "NO_MOTION", "no planned segment", [0, 0, 0, 0, 0, 0], [0, 0, 0])];
}
function padNumbers(values, length, fallback) {
const source = Array.isArray(values) ? values : fallback ?? [];
return Array.from({ length }, (_item, index) => numberOr(source[index], 0));
}
function numberOr(value, fallback) {
const number = Number(value);
return Number.isFinite(number) ? number : fallback;
}
function sourceMapLine(value) {
return numberOr(value?.sourceMap?.line ?? value?.source?.sourceMap?.line, 0);
}
function formatNumber(value, digits) {
const fixed = Number(value).toFixed(digits);
return fixed.replace(/\.?0+$/, "");
}
function formatArray(values, digits) {
return `[${values.map((value) => formatNumber(value, digits)).join(", ")}]`;
}
function setField(name, value) {
const item = fields.get(name);
if (item) item.textContent = value;
}
function resolvePackageUrl(relativePath) {
if (!relativePath) return "";
return new URL(`../${relativePath.replace(/^\/+/, "")}`, window.location.href).href;
}
function packageCacheKey(relativePath) {
if (!relativePath) return "";
try {
const url = new URL(resolvePackageUrl(relativePath));
return `package:${url.origin}${url.pathname}`;
} catch (_error) {
return `package:${relativePath}`;
}
}
function cacheFileName(key) {
try {
return window.btoa(key).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
} catch (_error) {
return key.replace(/[^a-z0-9_.-]/gi, "_").slice(0, 180);
}
}
function shortName(value) {
const name = String(value || "").split(/[?#]/)[0].split("/").pop() || String(value || "asset");
return name.length > 48 ? `${name.slice(0, 45)}...` : name;
}
const opfs = {
directory: undefined
};
async function opfsDirectory() {
if (opfs.directory) return opfs.directory;
opfs.directory = (async () => {
if (!navigator.storage?.getDirectory) {
app.storage.mode = "localStorage";
app.storage.last = "OPFS unavailable";
return null;
}
try {
const rootDirectory = await navigator.storage.getDirectory();
const directory = await rootDirectory.getDirectoryHandle("kdl-virtual-controller", { create: true });
app.storage.mode = "OPFS";
app.storage.last = "OPFS ready";
return directory;
} catch (error) {
app.storage.mode = "localStorage";
app.storage.errors += 1;
app.storage.last = `OPFS blocked: ${error instanceof Error ? error.name : "error"}`;
return null;
}
})();
return opfs.directory;
}
async function readOpfsText(key) {
const directory = await opfsDirectory();
if (!directory) return undefined;
try {
const handle = await directory.getFileHandle(cacheFileName(key));
const file = await handle.getFile();
return await file.text();
} catch (_error) {
return undefined;
}
}
async function writeOpfsText(key, text) {
const directory = await opfsDirectory();
if (!directory) return false;
try {
const handle = await directory.getFileHandle(cacheFileName(key), { create: true });
const writable = await handle.createWritable();
await writable.write(text);
await writable.close();
return true;
} catch (error) {
app.storage.errors += 1;
app.storage.last = `OPFS write failed: ${error instanceof Error ? error.name : "error"}`;
return false;
}
}
function readLocalStorageText(key) {
try {
const value = window.localStorage?.getItem(`kdl-vc-cache:${key}`);
return value ?? undefined;
} catch (_error) {
return undefined;
}
}
function writeLocalStorageText(key, text) {
try {
if (text.length > 2_000_000) return false;
window.localStorage?.setItem(`kdl-vc-cache:${key}`, text);
return true;
} catch (_error) {
return false;
}
}
async function readCachedTextByKey(key, label) {
if (!key) return undefined;
if (app.assetTextCache.has(key)) {
app.storage.hits += 1;
app.storage.last = `memory ${shortName(label ?? key)}`;
return app.assetTextCache.get(key);
}
const opfsText = await readOpfsText(key);
if (typeof opfsText === "string") {
app.assetTextCache.set(key, opfsText);
app.storage.hits += 1;
app.storage.last = `OPFS ${shortName(label ?? key)}`;
return opfsText;
}
const localStorageText = readLocalStorageText(key);
if (typeof localStorageText === "string") {
app.assetTextCache.set(key, localStorageText);
app.storage.hits += 1;
app.storage.last = `localStorage ${shortName(label ?? key)}`;
return localStorageText;
}
return undefined;
}
async function writeCachedTextByKey(key, text, label) {
if (!key || typeof text !== "string") return;
app.assetTextCache.set(key, text);
const opfsWritten = await writeOpfsText(key, text);
const localStorageWritten = writeLocalStorageText(key, text);
if (opfsWritten || localStorageWritten) {
app.storage.writes += 1;
app.storage.last = `${opfsWritten ? "OPFS" : "localStorage"} saved ${shortName(label ?? key)}`;
}
}
async function fetchTextFromNetwork(relativePath, options = {}) {
if (!relativePath) return undefined;
const controller = typeof AbortController === "function" ? new AbortController() : undefined;
const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : 3500;
const timeout = controller
? window.setTimeout(() => controller.abort(), timeoutMs)
: 0;
try {
const response = await fetch(resolvePackageUrl(relativePath), {
cache: "no-store",
signal: controller?.signal
});
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
const text = await response.text();
app.storage.network += 1;
app.storage.last = `network ${shortName(relativePath)}`;
void writeCachedTextByKey(packageCacheKey(relativePath), text, relativePath);
return text;
} catch (error) {
app.storage.errors += 1;
app.storage.last = `network ${shortName(relativePath)} ${error instanceof Error ? error.name : "error"}`;
return undefined;
} finally {
if (timeout) window.clearTimeout(timeout);
}
}
function refreshPackageAsset(relativePath, options = {}) {
const key = packageCacheKey(relativePath);
if (!key || app.assetRefreshes.has(key)) return;
app.assetRefreshes.add(key);
void fetchTextFromNetwork(relativePath, options).finally(() => {
app.assetRefreshes.delete(key);
});
}
async function optionalText(relativePath, options = {}) {
if (!relativePath) return "";
let triedNetwork = false;
if (options.networkFirst) {
triedNetwork = true;
const text = await fetchTextFromNetwork(relativePath, options);
if (typeof text === "string") return text;
}
const key = packageCacheKey(relativePath);
const cached = await readCachedTextByKey(key, relativePath);
if (typeof cached === "string") {
if (!options.networkFirst && options.refresh !== false) refreshPackageAsset(relativePath, options);
return cached;
}
if (triedNetwork) return "";
const text = await fetchTextFromNetwork(relativePath, options);
return text ?? "";
}
async function optionalJson(relativePath, options = {}) {
const text = await optionalText(relativePath, options);
if (!text) return undefined;
try {
return JSON.parse(text);
} catch (error) {
app.storage.errors += 1;
app.storage.last = `JSON ${shortName(relativePath)} ${error instanceof Error ? error.name : "error"}`;
return undefined;
}
}
function applyManifest(manifest) {
app.manifest = manifest;
if (manifest.release_id !== "local") {
for (const [programId, data] of app.programCache) {
if (data.releaseId && data.releaseId !== manifest.release_id && data.releaseId !== "local") {
app.programCache.delete(programId);
}
}
}
app.programs = Array.isArray(manifest.programs) && manifest.programs.length > 0
? manifest.programs
: fallbackManifest.programs;
app.selectedProgramId = chooseDefaultProgram(app.selectedProgramId);
app.report = manifest.report_html ? `${manifest.job_id} report` : app.report;
root?.setAttribute("data-robot", manifest.robot_id ?? "abb_irb120_3_58");
root?.setAttribute("data-suite", manifest.suite_id ?? "working2-abb120-spec");
root?.setAttribute("data-job", manifest.job_id ?? "W2-JOB-latest");
populateProgramSelect();
}
function chooseDefaultProgram(current) {
const ids = app.programs.map((program) => program.program_id);
if (current && ids.includes(current)) return current;
if (ids.includes("W2_60_IOWaitPulse")) return "W2_60_IOWaitPulse";
return ids[0] ?? "W2_60_IOWaitPulse";
}
function populateProgramSelect() {
if (!programSelect) return;
programSelect.textContent = "";
for (const program of app.programs) {
const option = document.createElement("option");
option.value = program.program_id;
option.textContent = `${program.program_id} (${program.coverage_level}/${program.status})`;
programSelect.append(option);
}
programSelect.value = app.selectedProgramId;
}
async function loadManifest() {
const manifest = await optionalJson("demo-manifest.json", {
networkFirst: true,
timeoutMs: 2500,
refresh: false
});
if (!manifest) return;
applyManifest(manifest);
void ensureProgramLoaded(app.selectedProgramId).then((data) => {
if (!data.isFallback) {
if (!["ready", "loading"].includes(app.command)) return;
startParseStream(app.selectedProgramId, "manifest");
}
});
resetExecutionClock();
render();
}
async function ensureProgramLoaded(programId) {
const cachedData = app.programCache.get(programId);
if (cachedData && cachedData.releaseId === app.manifest.release_id && (cachedData.isFallback || hasTrajectoryData(cachedData))) {
return cachedData;
}
if (app.programLoads.has(programId)) return app.programLoads.get(programId);
const load = loadProgramData(programId).finally(() => {
app.programLoads.delete(programId);
});
app.programLoads.set(programId, load);
return load;
}
async function loadProgramData(programId) {
const program = app.programs.find((item) => item.program_id === programId);
if (!program) return fallbackProgramData;
const artifacts = program.artifacts ?? {};
const [
sourceText,
trajectory,
controller,
motionQueue,
trace,
io,
compile,
postReport,
roundtrip
] = await Promise.all([
optionalText(program.source),
optionalJson(artifacts.trajectory),
optionalJson(artifacts.controller),
optionalJson(artifacts.motion_queue),
optionalJson(artifacts.trace),
optionalJson(artifacts.io),
optionalJson(artifacts.compile),
optionalJson(artifacts.post_report),
optionalJson(artifacts.roundtrip)
]);
const data = {
sourceText: sourceText || fallbackProgramData.sourceText,
trajectory: trajectory ?? {},
controller: controller ?? {},
motionQueue: motionQueue ?? {},
trace: Array.isArray(trace) ? trace : [],
io: io ?? {},
compile: compile ?? {},
postReport: postReport ?? {},
roundtrip: roundtrip ?? {},
isFallback: false,
releaseId: app.manifest.release_id ?? "local",
errors: []
};
if (hasTrajectoryData(data) || !app.programCache.has(programId)) {
app.programCache.set(programId, data);
}
return data;
}
function sourceLineCount(data) {
return (data.sourceText || fallbackProgramData.sourceText).split(/\r?\n/).length;
}
function sourceFrameCount(data) {
return Array.isArray(data?.trajectory?.frames) ? data.trajectory.frames.length : 0;
}
function visibleSourceFrameCount() {
const data = selectedData();
return Array.isArray(data?.trajectory?.frames) ? data.trajectory.frames.length : 0;
}
function stopParseStream() {
if (app.parseStream.timer) {
window.clearInterval(app.parseStream.timer);
app.parseStream.timer = 0;
}
app.parseStream.active = false;
}
function resetParseStreamSummary(message = "idle") {
stopParseStream();
app.parseStream.programId = "";
app.parseStream.cursorLine = 0;
app.parseStream.totalLines = 0;
app.parseStream.linesPerTick = 1;
app.parseStream.startedAt = 0;
app.parseStream.lastSummary = message;
}
function startParseStream(programId, reason) {
const data = cachedProgramData(programId);
const totalLines = sourceLineCount(data);
stopParseStream();
resetExecutionClock();
app.command = "parsing";
app.parseStream.active = true;
app.parseStream.programId = programId;
app.parseStream.cursorLine = 0;
app.parseStream.totalLines = totalLines;
app.parseStream.linesPerTick = Math.max(1, Math.ceil(totalLines / 24));
app.parseStream.startedAt = performance.now();
app.parseStream.completedProgramId = "";
app.parseStream.lastSummary = `parsing 0/${totalLines} lines`;
pushTrace(`${programId} parsing ${reason} from ${storageModeLabel()}`);
render();
app.parseStream.timer = window.setInterval(advanceParseStream, app.samplePeriodMs);
advanceParseStream();
}
function advanceParseStream() {
if (!app.parseStream.active) return;
const data = cachedProgramData(app.parseStream.programId);
app.parseStream.totalLines = sourceLineCount(data);
app.parseStream.cursorLine = Math.min(
app.parseStream.totalLines,
app.parseStream.cursorLine + app.parseStream.linesPerTick
);
syncExecutionClockToLatestSample();
const parsedFrames = visibleSourceFrameCount();
const totalFrames = sourceFrameCount(data);
app.parseStream.lastSummary = [
`parsing ${app.parseStream.cursorLine}/${app.parseStream.totalLines} lines`,
`frames ${Math.min(parsedFrames, totalFrames)}/${totalFrames}`,
`t=${formatNumber(app.playbackTime, 3)} s`
].join(", ");
if (app.parseStream.cursorLine >= app.parseStream.totalLines) {
finishParseStream("ready");
return;
}
render();
}
function finishParseStream(nextCommand = "ready") {
const programId = app.parseStream.programId || app.selectedProgramId;
const data = cachedProgramData(programId);
stopParseStream();
app.parseStream.cursorLine = sourceLineCount(data);
app.parseStream.totalLines = app.parseStream.cursorLine;
app.parseStream.completedProgramId = programId;
app.parseStream.lastSummary = `parsed ${app.parseStream.totalLines} lines, ${sourceFrameCount(data)} frames`;
app.command = nextCommand;
resetExecutionClock();
pushTrace(`${programId} parsed (${sourceFrameCount(data)} frames, ${executionSamples().length} samples @50ms)`);
render();
}
function finishParseStreamForCommand() {
if (!app.parseStream.active) return;
finishParseStream("ready");
}
function parserText() {
return app.parseStream.active ? app.parseStream.lastSummary : app.parseStream.lastSummary || "idle";
}
function parserProgressValue() {
if (app.parseStream.totalLines <= 0) return app.parseStream.active ? 0 : 1;
return Math.min(Math.max(app.parseStream.cursorLine / app.parseStream.totalLines, 0), 1);
}
async function selectProgram(programId) {
stopPlayback();
stopParseStream();
app.selectedProgramId = programId;
app.command = "loading";
resetExecutionClock();
app.di1 = false;
app.do1 = false;
app.wait = "none";
pushTrace(`${programId} loading from ${storageModeLabel()}`);
render();
try {
await ensureProgramLoaded(programId);
if (app.selectedProgramId !== programId) return;
startParseStream(programId, "selection");
} catch (error) {
if (app.selectedProgramId === programId) reportRuntimeError(`${programId} select`, error);
}
}
function commandTrace() {
const trace = selectedData().trace;
if (Array.isArray(trace) && trace.length > 0) return trace;
return [];
}
function diagnosticText() {
const data = selectedData();
const diagnostics = [
...((data.controller?.snapshot?.state?.diagnostics) ?? []),
...((data.trajectory?.diagnostics) ?? []),
...((data.postReport?.report) ?? [])
];
const latest = diagnostics.at(-1);
if (!latest) {
const program = selectedProgram();
return `${program?.status ?? "ready"} / ${program?.coverage_level ?? "Runtime"}`;
}
return `${latest.code ?? latest.severity}: ${latest.message ?? ""}`.trim();
}
function queueItems() {
const items = selectedData().motionQueue?.items;
return Array.isArray(items) ? items : [];
}
function queueText() {
const items = queueItems();
if (items.length === 0) return "empty";
return items.map((item) => {
const pathId = item.request?.pathId ?? item.source?.pathId ?? item.id;
const motions = Array.isArray(item.request?.segments)
? item.request.segments.map((segment) => segment.motion).join("/")
: item.kind ?? "motion";
return `${pathId}:${motions}`;
}).join(" -> ");
}
function ioText() {
const image = selectedData().io?.image ?? {};
const do1 = app.do1 || image["io.do[1]"] === true;
return `do[1]=${do1}, di[1]=${app.di1}, io=${Object.keys(image).length}`;
}
function storageModeLabel() {
const mode = app.storage.mode === "checking" ? "cache" : app.storage.mode;
return `${mode} h${app.storage.hits}/n${app.storage.network}/w${app.storage.writes}/e${app.storage.errors}`;
}
function storageText() {
return `${storageModeLabel()} ${app.storage.last}`;
}
function reportRuntimeError(context, error) {
const message = `${context}: ${error instanceof Error ? error.message : String(error)}`;
window.__vcErrors.push(message);
app.storage.errors += 1;
app.storage.last = message;
pushTrace(message);
render();
}
function sourceTextForTab(tab) {
const program = selectedProgram();
const data = selectedData();
if (tab === "grl") return data.sourceText || fallbackProgramData.sourceText;
const moduleName = data.compile?.moduleName ?? (program?.program_id ?? "W2Program").replace(/^W2_/, "W2");
const filenames = Array.isArray(data.postReport?.filenames) ? data.postReport.filenames : [];
if (tab === "abb") {
return [
`MODULE ${moduleName}`,
` ! ${filenames.find((name) => name.endsWith(".mod")) ?? `${moduleName}.mod`}`,
" PROC main()",
" ! See post-report.json for generated brand diagnostics.",
" ENDPROC",
"ENDMODULE"
].join("\n");
}
if (tab === "fanuc") {
return [
`/PROG ${moduleName.toUpperCase()}`,
`/ATTR`,
`COMMENT = "${filenames.find((name) => name.endsWith(".ls")) ?? `${moduleName}.ls`}";`,
"/MN",
" 1: ! See post-report.json ;",
"/END"
].join("\n");
}
return [
`DEF ${moduleName}()`,
` ; ${filenames.find((name) => name.endsWith(".src")) ?? `${moduleName}.src`}`,
" ; See post-report.json",
"END"
].join("\n");
}
function updateObjectTree() {
if (!objectTree) return;
const program = selectedProgram();
const items = queueItems();
const pathNames = [...new Set(items.map((item) => item.request?.pathId ?? item.source?.pathId).filter(Boolean))];
const entries = [
`Robot: ${app.manifest.robot_id ?? "abb_irb120_3_58"}`,
"Tool: tool0",
"Frame: world",
`Path: ${pathNames.join(", ") || "pick_place"}`,
`Program: ${program?.program_id ?? "W2_60_IOWaitPulse"}`,
`Coverage: ${program?.coverage_level ?? "Runtime"}`,
`Status: ${program?.status ?? "pass"}`,
`Frames: ${frames().length}`,
`Suite: ${app.manifest.suite_id ?? "working2-abb120-spec"}`,
`Job: ${app.manifest.job_id ?? "W2-JOB-latest"}`,
"Reports"
];
objectTree.textContent = "";
for (const entry of entries) {
const item = document.createElement("li");
item.textContent = entry;
objectTree.append(item);
}
}
function updateFrameSlider() {
if (!frameSlider) return;
const samples = currentSamples();
frameSlider.max = String(Math.max(samples.length - 1, 0));
frameSlider.value = String(Math.min(app.currentSampleIndex, Math.max(samples.length - 1, 0)));
}
function updatePathMarker() {
if (!pathCurrent) return;
const duration = trajectoryDuration();
const ratio = duration > 0 ? Math.min(Math.max(app.playbackTime / duration, 0), 1) : 0;
const x = 70 + ratio * 340;
const y = 170 - Math.sin(ratio * Math.PI) * 80 - ratio * 20;
pathCurrent.setAttribute("cx", String(formatNumber(x, 2)));
pathCurrent.setAttribute("cy", String(formatNumber(y, 2)));
}
function sourceLines() {
return (selectedData().sourceText || fallbackProgramData.sourceText).split(/\r?\n/);
}
function sourceLineText(line) {
const index = Math.round(numberOr(line, 0)) - 1;
return sourceLines()[index]?.trim() ?? "";
}
function mainProcedureRange() {
const lines = sourceLines();
const startIndex = lines.findIndex((line) => /^\s*proc\s+main\s*\(/i.test(line));
if (startIndex < 0) return { start: 0, end: 0 };
const startIndent = leadingSpaces(lines[startIndex]);
for (let index = startIndex + 1; index < lines.length; index += 1) {
if (/^\s*end\b/i.test(lines[index]) && leadingSpaces(lines[index]) <= startIndent) {
return { start: startIndex + 1, end: index + 1 };
}
}
return { start: startIndex + 1, end: lines.length };
}
function leadingSpaces(text) {
return String(text ?? "").match(/^\s*/)?.[0].length ?? 0;
}
function setupStepsBefore(firstLine) {
const range = mainProcedureRange();
if (range.start <= 0) return [];
const steps = [];
const lines = sourceLines();
const limit = Math.max(numberOr(firstLine, range.end + 1), range.start + 1);
for (let line = range.start + 1; line < Math.min(limit, range.end); line += 1) {
const text = lines[line - 1]?.trim() ?? "";
if (!/^(set_tool|set_frame)\b/i.test(text)) continue;
steps.push({
kind: "setup",
label: "Setup",
line,
text,
timeStart: 0,
timeEnd: 0,
sampleStart: 0,
sampleEnd: 0,
detail: text.replace(/\s+/g, " ")
});
}
return steps;
}
function traceInstructionSteps() {
const steps = [];
let lastKey = "";
for (const entry of commandTrace()) {
const line = numberOr(entry?.source?.sourceMap?.line, 0);
if (line <= 0 || entry?.kind === "load") continue;
const kind = entry.instructionKind ?? entry.source?.kind ?? entry.kind ?? "instruction";
const key = `${line}:${kind}`;
const time = numberOr(entry.time, 0);
if (key === lastKey && steps.length > 0) {
const previous = steps.at(-1);
previous.timeEnd = Math.max(numberOr(previous.timeEnd, time), time);
previous.detail = mergeDetails(previous.detail, traceDetail(entry));
continue;
}
const text = sourceLineText(line);
steps.push({
kind: String(kind).toLowerCase(),
label: stepLabel(kind, text),
line,
text,
timeStart: time,
timeEnd: time,
pc: numberOr(entry.pc, 0),
traceKind: entry.kind,
detail: traceDetail(entry)
});
lastKey = key;
}
return steps;
}
function traceDetail(entry) {
const data = entry?.data ?? {};
if (Array.isArray(data.result) && data.result.length > 0) {
return data.result.map((item) => {
const target = item.target?.raw ?? item.target?.domain ?? item.kind ?? "io";
return `${target}=${String(item.value)}`;
}).join(", ");
}
if (data.status) return `status=${data.status}`;
if (data.queued === true) return "queued";
if (data.alarmId) return `alarm=${data.alarmId}`;
if (entry?.diagnostic?.code) return entry.diagnostic.code;
if (entry?.message) return entry.message;
return "";
}
function mergeDetails(left, right) {
if (!right) return left ?? "";
if (!left) return right;
return left.includes(right) ? left : `${left}; ${right}`;
}
function stepLabel(kind, text) {
const source = String(text ?? "").trim().toLowerCase();
const normalized = String(kind ?? "").toUpperCase();
if (/^set_(tool|frame)\b/.test(source)) return "Setup";
if (/^run_operation\b/.test(source) || normalized === "RUN_OPERATION") return "Operation";
if (/^run_path\b/.test(source) || normalized === "RUN_PATH") return "Path";
if (/^point\b/.test(source)) return "Path Point";
if (/^event\b/.test(source)) return "Path Event";
if (/^(movej|movel|movec|moveabsj)\b/.test(source) || /^MOVE/.test(normalized)) return "Motion";
if (/^wait\b/.test(source) || normalized === "WAIT") return "Wait";
if (/^pulse\b/.test(source) || normalized === "PULSE") return "Pulse";
if (/^io\./.test(source) || normalized === "IO_WRITE") return "IO";
if (/^(if|elseif|else|while|for|switch|case|break|continue)\b/.test(source) || normalized.startsWith("EXEC_")) return "Control";
if (/^call\b/.test(source)) return "Call";
if (/^return\b/.test(source)) return "Return";
if (normalized === "ALARM") return "Alarm";
return normalized.replace(/^RAW_/, "").replace(/_/g, " ").toLowerCase();
}
function sampleGroups(samples = currentSamples()) {
const groups = [];
for (const sample of samples) {
const line = numberOr(sample?.sourceMap?.line, 0);
if (line <= 0) continue;
const key = [
line,
sample.motion ?? "",
sample.targetId ?? sample.segmentId ?? ""
].join(":");
const previous = groups.at(-1);
if (previous?.key === key && previous.sampleEnd + 1 === sample.index) {
previous.sampleEnd = sample.index;
previous.timeEnd = numberOr(sample.time, previous.timeEnd);
} else {
groups.push({
id: `sample:${groups.length}`,
key,
line,
motion: sample.motion ?? "",
target: sample.targetId ?? sample.segmentId ?? "",
sampleStart: sample.index,
sampleEnd: sample.index,
timeStart: numberOr(sample.time, 0),
timeEnd: numberOr(sample.time, 0)
});
}
}
return groups;
}
function applySampleGroup(step, group) {
if (!group) return step;
step.motion = group.motion || step.motion;
step.target = group.target || step.target;
step.sampleStart = group.sampleStart;
step.sampleEnd = group.sampleEnd;
step.timeStart = Math.min(numberOr(step.timeStart, group.timeStart), group.timeStart);
step.timeEnd = Math.max(numberOr(step.timeEnd, group.timeEnd), group.timeEnd);
return step;
}
function nearestSampleIndex(time, samples = currentSamples()) {
if (samples.length === 0) return 0;
let bestIndex = 0;
let bestDistance = Infinity;
for (const sample of samples) {
const distance = Math.abs(numberOr(sample.time, 0) - numberOr(time, 0));
if (distance < bestDistance) {
bestDistance = distance;
bestIndex = sample.index;
}
}
return bestIndex;
}
function findUnusedGroup(line, groups, usedGroupIds) {
const group = groups.find((item) => item.line === line && !usedGroupIds.has(item.id))
?? groups.find((item) => item.line === line);
if (group) usedGroupIds.add(group.id);
return group;
}
function queueChildrenForStep(step, groups, usedGroupIds) {
const children = [];
for (const item of queueItems()) {
const parentLine = numberOr(item?.source?.sourceMap?.line ?? item?.source?.instruction?.sourceMap?.line, 0);
if (parentLine !== step.line) continue;
const request = item.request ?? {};
const segments = Array.isArray(request.segments) ? request.segments : [];
const events = Array.isArray(request.events) ? request.events : [];
const eventsByPoint = new Map();
const looseEvents = [];
for (const event of events) {
const pointId = event.pointId ?? "";
if (!pointId) {
looseEvents.push(event);
continue;
}
if (!eventsByPoint.has(pointId)) eventsByPoint.set(pointId, []);
eventsByPoint.get(pointId).push(event);
}
for (const segment of segments) {
const beforeEvents = (eventsByPoint.get(segment.id) ?? []).filter((event) => event.timing === "before");
for (const event of beforeEvents) children.push(queueEventStep(event, step));
children.push(queueSegmentStep(segment, step, groups, usedGroupIds));
const afterEvents = (eventsByPoint.get(segment.id) ?? []).filter((event) => event.timing !== "before");
for (const event of afterEvents) children.push(queueEventStep(event, step, segment, groups));
}
for (const event of looseEvents) children.push(queueEventStep(event, step));
}
return children.filter((child) => child.line > 0 && child.line !== step.line);
}
function queueSegmentStep(segment, parent, groups, usedGroupIds) {
const line = numberOr(segment?.sourceMap?.line, 0);
const text = sourceLineText(line);
const step = {
kind: "path-point",
label: stepLabel(segment?.motion ?? "MOVE", text),
line,
text,
timeStart: numberOr(parent.timeStart, 0),
timeEnd: numberOr(parent.timeEnd, 0),
motion: segment?.motion ?? "",
target: segment?.targetId ?? segment?.target?.id ?? segment?.id ?? "",
detail: `${segment?.motion ?? "MOVE"} ${segment?.targetId ?? segment?.target?.id ?? segment?.id ?? ""}`.trim()
};
return applySampleGroup(step, findUnusedGroup(line, groups, usedGroupIds));
}
function queueEventStep(event, parent, segment, groups = []) {
const line = numberOr(event?.sourceMap?.line, 0);
const text = sourceLineText(line);
const segmentGroup = segment
? groups.find((group) => group.line === numberOr(segment?.sourceMap?.line, 0))
: undefined;
const time = event?.timing === "before"
? numberOr(segmentGroup?.timeStart, numberOr(parent.timeStart, 0))
: numberOr(segmentGroup?.timeEnd, numberOr(parent.timeEnd, numberOr(parent.timeStart, 0)));
const sample = event?.timing === "before"
? numberOr(segmentGroup?.sampleStart, parent.sampleStart)
: numberOr(segmentGroup?.sampleEnd, parent.sampleEnd);
return {
kind: "path-event",
label: stepLabel("EVENT", text),
line,
text,
timeStart: time,
timeEnd: time,
sampleStart: Number.isFinite(sample) ? sample : undefined,
sampleEnd: Number.isFinite(sample) ? sample : undefined,
detail: `${event?.timing ?? "event"} ${event?.pointId ?? ""} ${event?.kind ?? ""}`.trim()
};
}
function buildProgramSteps(samples = currentSamples()) {
const groups = sampleGroups(samples);
const usedGroupIds = new Set();
const traceSteps = traceInstructionSteps();
const firstTraceLine = traceSteps.find((step) => step.line > 0)?.line ?? 0;
const steps = setupStepsBefore(firstTraceLine);
const seenSourceRows = new Set(steps.map((step) => `${step.line}:${step.kind}`));
for (const traceStep of traceSteps) {
const step = { ...traceStep };
applySampleGroup(step, findUnusedGroup(step.line, groups, usedGroupIds));
if (step.sampleStart === undefined && step.timeStart !== undefined) {
const sample = nearestSampleIndex(step.timeStart, samples);
step.sampleStart = sample;
step.sampleEnd = sample;
}
const childSteps = queueChildrenForStep(step, groups, usedGroupIds);
const childGroups = childSteps.filter((child) => child.sampleStart !== undefined);
if (childGroups.length > 0 && !step.motion) {
step.sampleStart = Math.min(...childGroups.map((child) => child.sampleStart));
step.sampleEnd = Math.max(...childGroups.map((child) => child.sampleEnd));
step.timeStart = Math.min(...childGroups.map((child) => child.timeStart));
step.timeEnd = Math.max(...childGroups.map((child) => child.timeEnd));
}
const key = `${step.line}:${step.kind}:${step.pc ?? ""}`;
if (!seenSourceRows.has(key)) {
steps.push(step);
seenSourceRows.add(key);
}
for (const child of childSteps) {
const childKey = `${child.line}:${child.kind}:${child.detail ?? ""}`;
if (seenSourceRows.has(childKey)) continue;
steps.push(child);
seenSourceRows.add(childKey);
}
}
for (const group of groups) {
if (usedGroupIds.has(group.id)) continue;
const text = sourceLineText(group.line);
steps.push({
kind: "motion",
label: stepLabel(group.motion, text),
line: group.line,
text,
timeStart: group.timeStart,
timeEnd: group.timeEnd,
sampleStart: group.sampleStart,
sampleEnd: group.sampleEnd,
motion: group.motion,
target: group.target,
detail: `${group.motion} ${group.target}`.trim()
});
usedGroupIds.add(group.id);
}
if (steps.length === 0) {
const item = currentFrame();
steps.push({
kind: "sample",
label: "Sample",
line: numberOr(item?.sourceMap?.line, 0),
text: item?.sourceMap ? sourceLineText(item.sourceMap.line) : "sample trajectory",
timeStart: app.playbackTime,
timeEnd: app.playbackTime,
sampleStart: app.currentSampleIndex,
sampleEnd: app.currentSampleIndex,
motion: item?.motion ?? "",
target: item?.targetId ?? item?.segmentId ?? ""
});
}
return steps.map((step, index) => ({
...step,
index: index + 1,
id: `step:${index + 1}:${step.line}:${step.kind}`
}));
}
function activeProgramStep(steps) {
if (steps.length === 0) return undefined;
const item = currentFrame();
const line = numberOr(item?.sourceMap?.line, 0);
if (line > 0) {
const matchingMotion = steps.find((step) => {
return step.line === line
&& step.sampleStart !== undefined
&& step.sampleEnd !== undefined
&& step.sampleStart <= app.currentSampleIndex
&& step.sampleEnd >= app.currentSampleIndex;
});
if (matchingMotion) return matchingMotion;
}
const matchingSample = steps.find((step) => {
return step.sampleStart !== undefined
&& step.sampleEnd !== undefined
&& step.sampleStart <= app.currentSampleIndex
&& step.sampleEnd >= app.currentSampleIndex;
});
if (matchingSample) return matchingSample;
const elapsedSteps = steps.filter((step) => numberOr(step.timeStart, 0) <= app.playbackTime + 1e-9);
return elapsedSteps.at(-1) ?? steps[0];
}
function stepState(step, active) {
if (!active) return "pending";
if (step.id === active.id) return "active";
if (step.sampleEnd !== undefined && active.sampleStart !== undefined && step.sampleEnd < active.sampleStart) {
return "complete";
}
if (step.index < active.index && numberOr(step.timeEnd, 0) <= app.playbackTime + 1e-9) {
return "complete";
}
return "pending";
}
function renderStepTable(steps, active) {
if (!stepTable) return;
stepTable.textContent = "";
for (const step of steps) {
const row = document.createElement("tr");
row.dataset.stepState = stepState(step, active);
if (step.id === active?.id) row.setAttribute("aria-current", "true");
const cells = [
step.index,
stepTimeText(step),
step.line > 0 ? step.line : "",
step.label,
step.text,
stepMotionText(step),
stepSampleText(step)
];
for (const cell of cells) {
const td = document.createElement("td");
td.textContent = String(cell);
row.append(td);
}
stepTable.append(row);
}
}
function stepTimeText(step) {
const start = numberOr(step.timeStart, 0);
const end = numberOr(step.timeEnd, start);
if (Math.abs(end - start) > 1e-9) {
return `${formatNumber(start, 3)}-${formatNumber(end, 3)} s`;
}
return `${formatNumber(start, 3)} s`;
}
function stepMotionText(step) {
if (step.motion || step.target) {
return `${step.motion ?? ""}${step.target ? ` / ${step.target}` : ""}`.trim();
}
return step.detail ?? "";
}
function stepSampleText(step) {
if (step.sampleStart === undefined || step.sampleEnd === undefined) return "-";
const start = numberOr(step.sampleStart, 0) + 1;
const end = numberOr(step.sampleEnd, step.sampleStart) + 1;
return start === end ? `${start} @50ms` : `${start}-${end} @50ms`;
}
function renderFrameTable() {
if (!frameTable) return;
const items = frames();
frameTable.textContent = "";
for (const item of items) {
const row = document.createElement("tr");
if (item.index === app.currentFrameIndex) row.setAttribute("aria-current", "true");
const cells = [
item.index,
formatNumber(item.time, 4),
item.motion,
item.targetId ?? item.segmentId ?? "",
formatArray(item.tcp.position, 6),
formatArray(item.tcp.quaternion, 6),
...item.joints.map((joint) => formatNumber(joint, 6))
];
for (const cell of cells) {
const td = document.createElement("td");
td.textContent = String(cell);
row.append(td);
}
frameTable.append(row);
}
}
function render() {
const program = selectedProgram();
const data = selectedData();
const item = currentFrame();
const trace = commandTrace();
const traceLast = trace.at(-1);
const totalFrames = frames().length;
const samples = currentSamples();
const programSteps = buildProgramSteps(samples);
const activeStep = activeProgramStep(programSteps);
const totalSamples = samples.length;
const duration = samples.at(-1)?.time ?? trajectoryDuration();
const sourceMap = item.sourceMap ? `:${item.sourceMap.line ?? 0}` : "";
root?.setAttribute("data-last-command", app.command);
root?.setAttribute("data-running", String(app.command === "running"));
if (programSelect && programSelect.value !== app.selectedProgramId) {
programSelect.value = app.selectedProgramId;
}
setField("job", app.manifest.job_id ?? "W2-JOB-latest");
setField("state", app.command);
setField("program", program?.program_id ?? "W2_60_IOWaitPulse");
setField("mode", app.mode);
setField("motors", app.motors);
setField("pc", `sample:${app.currentSampleIndex}${sourceMap}`);
setField(
"runtime",
`${formatNumber(app.playbackTime, 3)} / ${formatNumber(duration, 3)} s @ ${app.samplePeriodMs} ms`
);
setField("joints", formatArray(item.joints, 6));
setField("tcp", `pos ${formatArray(item.tcp.position, 6)} q ${formatArray(item.tcp.quaternion, 6)}`);
setField("log", app.localTrace.at(-1) ?? traceLast?.message ?? "ready");
setField("diagnostics", diagnosticText());
setField("io", ioText());
setField("wait", app.wait);
setField("queue", queueText());
setField("trace", `${trace.length + app.localTrace.length} events`);
setField("reports", app.report);
setField("parser", parserText());
setField("storage", storageText());
setField("editor", sourceTextForTab(app.tab));
setField("frame", totalSamples > 0 ? `${app.currentSampleIndex + 1} / ${totalSamples}` : "0 / 0");
setField(
"step-status",
activeStep
? `${activeStep.index}/${programSteps.length} ${activeStep.label} line ${activeStep.line || "-"} t=${formatNumber(app.playbackTime, 3)} s`
: "0 / 0"
);
const sampleCount = Number.isFinite(Number(data.trajectory?.sampleCount))
? Number(data.trajectory.sampleCount)
: (data.isFallback ? totalFrames : 0);
setField(
"frame-time",
`t=${formatNumber(app.playbackTime, 3)} / ${formatNumber(duration, 3)} s, step=${formatNumber(app.samplePeriodSec, 2)} s, source ${app.currentFrameIndex + 1}/${totalFrames} (${sampleCount} trajectory frames)`
);
for (const button of tabButtons) {
button.setAttribute("aria-pressed", String(button.dataset.tab === app.tab));
}
if (parserProgress) {
parserProgress.value = parserProgressValue();
}
updateObjectTree();
updateFrameSlider();
updatePathMarker();
renderStepTable(programSteps, activeStep);
renderFrameTable();
}
function pushTrace(message) {
app.localTrace.push(message);
if (app.localTrace.length > 100) app.localTrace.shift();
}
function advanceSample(stepSize) {
setSampleIndex(app.currentSampleIndex + stepSize);
}
function startPlayback() {
finishParseStreamForCommand();
stopPlayback();
app.playbackStartedAt = performance.now();
app.playbackTimeStart = app.playbackTime;
app.playbackSampleIndex = app.currentSampleIndex;
app.playbackTimer = window.setInterval(() => {
if (app.command !== "running") {
stopPlayback();
return;
}
const elapsed = (performance.now() - app.playbackStartedAt) / 1000;
const before = app.currentSampleIndex;
setPlaybackTime(app.playbackTimeStart + elapsed);
if (before !== app.currentSampleIndex) {
app.playbackSampleIndex = app.currentSampleIndex;
}
if (app.currentSampleIndex >= Math.max(currentSamples().length - 1, 0)) {
stopPlayback();
app.command = "ready";
pushTrace(`${app.selectedProgramId} completed t=${formatNumber(app.playbackTime, 3)}s`);
}
render();
}, app.samplePeriodMs);
}
function stopPlayback() {
if (app.playbackTimer) {
window.clearInterval(app.playbackTimer);
app.playbackTimer = 0;
}
}
async function handleCommand(command) {
root?.setAttribute("data-last-command", command);
if (command === "load") {
const programId = app.selectedProgramId;
stopPlayback();
stopParseStream();
app.command = "load";
resetExecutionClock();
app.di1 = false;
app.do1 = false;
app.wait = "none";
pushTrace(`${programId} loading from ${storageModeLabel()}`);
render();
void ensureProgramLoaded(programId).then(() => {
if (app.selectedProgramId !== programId) return;
if (!["load", "loading"].includes(app.command)) return;
startParseStream(programId, "load");
}).catch((error) => reportRuntimeError(`${programId} load`, error));
return;
} else if (command === "run") {
const programId = app.selectedProgramId;
finishParseStreamForCommand();
app.command = "running";
app.do1 = true;
if (app.currentSampleIndex >= Math.max(currentSamples().length - 1, 0)) {
resetExecutionClock();
}
advanceSample(1);
app.wait = app.di1 ? "satisfied" : "waiting di[1]";
pushTrace(`${programId} running sample ${app.currentSampleIndex + 1} t=${formatNumber(app.playbackTime, 3)}s`);
startPlayback();
render();
void ensureProgramLoaded(programId).then(() => {
if (app.selectedProgramId === programId && app.command === "running") render();
}).catch((error) => reportRuntimeError(`${programId} run load`, error));
return;
} else if (command === "pause") {
stopPlayback();
app.command = "paused";
pushTrace(`${app.selectedProgramId} paused`);
} else if (command === "step") {
stopPlayback();
finishParseStreamForCommand();
app.command = "paused";
advanceSample(1);
pushTrace(`${app.selectedProgramId} stepped sample ${app.currentSampleIndex + 1} t=${formatNumber(app.playbackTime, 3)}s`);
} else if (command === "stop") {
stopPlayback();
stopParseStream();
app.command = "stopped";
app.wait = "none";
pushTrace(`${app.selectedProgramId} stopped`);
} else if (command === "reset") {
stopPlayback();
stopParseStream();
app.command = "ready";
resetExecutionClock();
app.di1 = false;
app.do1 = false;
app.wait = "none";
pushTrace("controller reset");
} else if (command === "export") {
app.report = `${app.manifest.job_id ?? "W2-JOB-latest"} report exported`;
pushTrace("delivery package exported");
}
render();
}
for (const button of document.querySelectorAll("[data-command]")) {
button.addEventListener("click", () => {
const command = button.dataset.command ?? "ready";
void handleCommand(command).catch((error) => reportRuntimeError(command, error));
});
}
for (const button of tabButtons) {
button.addEventListener("click", () => {
app.tab = button.dataset.tab ?? "grl";
pushTrace(`${app.tab.toUpperCase()} source selected`);
render();
});
}
programSelect?.addEventListener("change", () => {
void selectProgram(programSelect.value).catch((error) => reportRuntimeError("program select", error));
});
frameSlider?.addEventListener("input", () => {
stopPlayback();
setSampleIndex(numberOr(frameSlider.value, 0));
app.command = app.command === "running" ? "paused" : app.command;
pushTrace(`${app.selectedProgramId} sample ${app.currentSampleIndex + 1} t=${formatNumber(app.playbackTime, 3)}s`);
render();
});
document.querySelector("[data-io='di1']")?.addEventListener("click", () => {
app.di1 = !app.di1;
app.wait = app.di1 ? "satisfied" : "waiting di[1]";
pushTrace(`di[1]=${app.di1}`);
render();
});
applyManifest(fallbackManifest);
render();
void loadManifest();
})();