1804 lines
62 KiB
JavaScript
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();
|
|
})();
|