Advance AXIS-style Three.js simulation UI

This commit is contained in:
2026-06-17 09:59:22 +08:00
parent d6a623308b
commit 656cddf73a
24 changed files with 84658 additions and 75 deletions

View File

@@ -0,0 +1,205 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>LinuxCNC AXIS Screenshot Smoke</title>
<style>
html,
body {
margin: 0;
width: 100%;
height: 100%;
overflow: hidden;
background: #bfc5c9;
}
iframe {
position: fixed;
inset: 0;
width: 100vw;
height: 100vh;
border: 0;
}
#status,
#axis-diagnostics-artifact {
position: fixed;
left: -10000px;
top: 0;
width: 1px;
height: 1px;
overflow: hidden;
}
</style>
</head>
<body>
<pre id="status">running</pre>
<pre id="axis-diagnostics-artifact">{}</pre>
<script type="module">
const status = document.getElementById("status");
const artifactNode = document.getElementById("axis-diagnostics-artifact");
const okStatusPrefix = ["axis_screenshot_smoke", "ok"].join("=");
const failStatusPrefix = ["axis_screenshot_smoke", "fail"].join("=");
async function loadFrame() {
const frame = document.createElement("iframe");
frame.src = `../../runtime/ui/simulation/index.html${window.location.search}`;
frame.width = `${window.innerWidth}`;
frame.height = `${window.innerHeight}`;
document.body.append(frame);
await new Promise((resolve, reject) => {
frame.addEventListener("load", resolve, { once: true });
frame.addEventListener("error", reject, { once: true });
});
return frame;
}
async function waitForReady(frame) {
for (let i = 0; i < 1000; i += 1) {
const state = frame.contentWindow?.linuxCncRealSimulationState;
const canvas = frame.contentDocument?.querySelector("[data-toolpath-three]");
const api = frame.contentWindow?.linuxCncRealSimulationApi;
if (state?.summary?.ready && api?.exportDiagnosticsArtifact && canvas) {
return state;
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
throw new Error("AXIS screenshot smoke did not reach ready simulation API state");
}
function rectOf(doc, selector) {
const node = doc.querySelector(selector);
if (!node) {
throw new Error(`missing screenshot target ${selector}`);
}
return node.getBoundingClientRect();
}
function assertVisibleRect(name, rect, viewport) {
if (rect.width <= 0 || rect.height <= 0) {
throw new Error(`${name} has empty rect ${JSON.stringify(rect.toJSON?.() ?? rect)}`);
}
if (rect.left < -2 || rect.right > viewport.width + 2) {
throw new Error(`${name} overflows viewport horizontally: ${rect.left}..${rect.right} of ${viewport.width}`);
}
if (rect.top < -2 || rect.top > viewport.height + 2) {
throw new Error(`${name} starts outside viewport vertically: ${rect.top} of ${viewport.height}`);
}
}
function intersectionArea(a, b) {
const x = Math.max(0, Math.min(a.right, b.right) - Math.max(a.left, b.left));
const y = Math.max(0, Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top));
return x * y;
}
function assertNoOverlap(name, a, b) {
const area = intersectionArea(a, b);
if (area > 1) {
throw new Error(`${name} overlap area ${area}`);
}
}
function assertCanvasPixel(canvas) {
const gl = canvas.getContext("webgl2") || canvas.getContext("webgl");
if (!gl) {
throw new Error("AXIS screenshot smoke missing WebGL context");
}
const pixel = new Uint8Array(4);
gl.readPixels(Math.floor(canvas.width / 2), Math.floor(canvas.height / 2), 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, pixel);
if (pixel[0] === 0 && pixel[1] === 0 && pixel[2] === 0 && pixel[3] === 0) {
throw new Error("AXIS screenshot smoke Three.js center pixel is blank");
}
}
try {
const frame = await loadFrame();
await waitForReady(frame);
frame.contentWindow.dispatchEvent(new Event("resize"));
await new Promise((resolve) => setTimeout(resolve, 100));
const doc = frame.contentDocument;
const viewport = {
width: frame.contentWindow.innerWidth,
height: frame.contentWindow.innerHeight,
};
const canvas = doc.querySelector("[data-toolpath-three]");
const canvasRect = canvas.getBoundingClientRect();
const hudRect = rectOf(doc, '[data-axis-shell="preview-hud"]');
const legendRect = rectOf(doc, '[data-axis-shell="preview-legend"]');
const diagnosticsRect = rectOf(doc, '[data-axis-shell="diagnostics"]');
const toolTableRect = rectOf(doc, '[data-axis-shell="tool-table"]');
const limitsHomeRect = rectOf(doc, '[data-axis-shell="limits-home"]');
const historyRect = rectOf(doc, '[data-axis-shell="status-history"]');
const gcodeRect = rectOf(doc, '[data-axis-shell="gcode-pane"]');
const machineRect = rectOf(doc, '[data-axis-shell="machine-state"]');
const statusbarRect = rectOf(doc, '[data-axis-shell="statusbar"]');
const activeCodesRect = rectOf(doc, '[data-axis-shell="active-codes"]');
for (const [name, rect] of [
["three canvas", canvasRect],
["preview HUD", hudRect],
["preview legend", legendRect],
["diagnostics", diagnosticsRect],
["tool table", toolTableRect],
["limits/home", limitsHomeRect],
["status history", historyRect],
["gcode pane", gcodeRect],
["machine state", machineRect],
["statusbar", statusbarRect],
["active modal codes", activeCodesRect],
]) {
assertVisibleRect(name, rect, viewport);
}
const modalText = doc.querySelector("[data-modal-rows]")?.textContent ?? "";
if (!modalText.includes("Plane:") || !modalText.includes("Origin:")) {
throw new Error(`AXIS screenshot smoke missing LinuxCNC modal code rows: ${modalText}`);
}
assertNoOverlap("gcode/machine state", gcodeRect, machineRect);
assertNoOverlap("diagnostics/status history", diagnosticsRect, historyRect);
if (hudRect.left < canvasRect.left || hudRect.top < canvasRect.top || hudRect.right > canvasRect.right + 1 || hudRect.bottom > canvasRect.bottom + 1) {
throw new Error("preview HUD should remain inside the Three.js canvas stage");
}
const statusbarText = doc.querySelector('[data-axis-shell="statusbar"]')?.textContent ?? "";
if (!statusbarText.includes("Tool") || !statusbarText.includes("Line") || !statusbarText.includes("Preview")) {
throw new Error(`AXIS screenshot smoke missing structured statusbar fields: ${statusbarText}`);
}
if (toolTableRect.top < diagnosticsRect.top || toolTableRect.bottom > diagnosticsRect.bottom + 1) {
throw new Error("tool table should remain inside diagnostics panel");
}
if (limitsHomeRect.top < diagnosticsRect.top || limitsHomeRect.bottom > diagnosticsRect.bottom + 1) {
throw new Error("limits/home panel should remain inside diagnostics panel");
}
if (legendRect.top < canvasRect.top || legendRect.bottom < canvasRect.bottom) {
throw new Error("preview legend should sit below the Three.js canvas");
}
assertCanvasPixel(canvas);
if (!doc.querySelector("[data-tool-table-rows]") || !doc.querySelector("[data-limits-home-rows]") || !doc.querySelector("[data-status-history]")) {
throw new Error("AXIS screenshot smoke missing compact panels");
}
const artifact = frame.contentWindow.linuxCncRealSimulationApi?.exportDiagnosticsArtifact?.();
if (artifact?.apiName !== "real-browser-simulation-diagnostics-artifact") {
throw new Error("AXIS screenshot smoke missing diagnostics artifact");
}
if (
artifact.preview?.renderer !== "threejs" ||
artifact.preview?.three?.pathPoints <= 0 ||
artifact.preview?.three?.toolMarkerObjects < 3 ||
artifact.preview?.three?.scaleBarObjects !== 4 ||
artifact.preview?.three?.orientationTriadObjects !== 6 ||
!artifact.preview?.three?.toolGeometry ||
!artifact.preview?.three?.scaleBar ||
!artifact.preview?.three?.orientationTriad
) {
throw new Error(`AXIS screenshot smoke diagnostics missing Three.js preview state: ${JSON.stringify(artifact.preview)}`);
}
artifact.viewport = viewport;
artifactNode.textContent = JSON.stringify(artifact);
status.textContent = `${okStatusPrefix} ${viewport.width}x${viewport.height}`;
} catch (error) {
status.textContent = `${failStatusPrefix} ${error.stack || error.message}`;
throw error;
}
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,302 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")/../.." && pwd)"
CHROMIUM="${CHROMIUM:-$(command -v chromium || command -v chromium-browser || command -v google-chrome || command -v google-chrome-stable || true)}"
if [[ -z "$CHROMIUM" ]]; then
echo "missing Chromium-compatible browser; set CHROMIUM=/path/to/browser" >&2
exit 1
fi
if [[ "${SKIP_INTERP_BUILD:-0}" != "1" ]]; then
"$ROOT_DIR/tools/build_wasm_core.sh"
fi
TMP_DIR="$(mktemp -d)"
PORT_FILE="$TMP_DIR/port"
SERVER_LOG="$TMP_DIR/server.log"
CHROME_PROFILE="$TMP_DIR/chrome-profile"
mkdir -p "$CHROME_PROFILE"
ARTIFACT_DIR="${AXIS_SCREENSHOT_ARTIFACT_DIR:-}"
FULL_DIAGNOSTICS="${AXIS_SCREENSHOT_FULL_DIAGNOSTICS:-0}"
if [[ -n "$ARTIFACT_DIR" ]]; then
mkdir -p "$ARTIFACT_DIR"
fi
cleanup() {
if [[ -n "${SERVER_PID:-}" ]]; then
kill "$SERVER_PID" 2>/dev/null || true
wait "$SERVER_PID" 2>/dev/null || true
fi
rm -rf "$TMP_DIR"
}
trap cleanup EXIT
python3 - <<'PY' "$ROOT_DIR" "$PORT_FILE" >"$SERVER_LOG" 2>&1 &
import functools
import http.server
import pathlib
import socketserver
import sys
root = pathlib.Path(sys.argv[1])
port_file = pathlib.Path(sys.argv[2])
handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=str(root))
with socketserver.TCPServer(("127.0.0.1", 0), handler) as httpd:
port_file.write_text(str(httpd.server_address[1]), encoding="ascii")
httpd.serve_forever()
PY
SERVER_PID=$!
for _ in $(seq 1 100); do
[[ -s "$PORT_FILE" ]] && break
sleep 0.05
done
if [[ ! -s "$PORT_FILE" ]]; then
echo "AXIS screenshot browser smoke HTTP server did not start" >&2
cat "$SERVER_LOG" >&2 || true
exit 1
fi
PORT="$(cat "$PORT_FILE")"
SMOKE_URL="http://127.0.0.1:$PORT/tests/browser/real_simulation_page_smoke.html"
SCREENSHOT_URL="http://127.0.0.1:$PORT/runtime/ui/simulation/index.html"
run_case() {
local name="$1"
local size="$2"
local query="${3:-}"
local out="$TMP_DIR/axis-${name}.dom"
local shot="$TMP_DIR/axis-${name}.png"
local diagnostics_json="$TMP_DIR/axis-${name}-diagnostics.json"
local full_diagnostics_json="$TMP_DIR/axis-${name}-full-diagnostics.json"
local screenshot_url="$SCREENSHOT_URL$query"
"$CHROMIUM" \
--headless=new \
--disable-gpu \
--no-sandbox \
--user-data-dir="$CHROME_PROFILE" \
--virtual-time-budget=10000 \
--dump-dom \
"$SMOKE_URL" >"$out" 2>&1
if ! grep -Fq "browser_real_simulation_page_smoke=ok" "$out"; then
echo "AXIS screenshot prerequisite smoke failed for $name" >&2
sed -n '1,260p' "$out" >&2
exit 1
fi
"$CHROMIUM" \
--headless=new \
--disable-gpu \
--no-sandbox \
--user-data-dir="$CHROME_PROFILE" \
--window-size="$size" \
--virtual-time-budget=60000 \
--screenshot="$shot" \
"$screenshot_url" \
>/dev/null 2>&1
if [[ ! -s "$shot" ]]; then
echo "AXIS screenshot output missing for $name" >&2
exit 1
fi
local bytes
bytes="$(wc -c <"$shot")"
if [[ "$bytes" -lt 10000 ]]; then
echo "AXIS screenshot output too small for $name: $bytes bytes" >&2
exit 1
fi
if [[ "$FULL_DIAGNOSTICS" == "1" ]]; then
local cdp_profile="$TMP_DIR/cdp-${name}"
mkdir -p "$cdp_profile"
"$CHROMIUM" \
--headless=new \
--disable-gpu \
--no-sandbox \
--remote-debugging-port=0 \
--user-data-dir="$cdp_profile" \
--window-size="$size" \
"about:blank" \
>/dev/null 2>&1 &
local cdp_pid=$!
node - "$cdp_profile" "$full_diagnostics_json" "$screenshot_url" <<'NODE'
const [profile, targetPath, targetUrl] = process.argv.slice(2);
const fs = await import("node:fs/promises");
const path = await import("node:path");
async function sleep(ms) {
await new Promise((resolve) => setTimeout(resolve, ms));
}
async function readDevToolsPort() {
const portFile = path.resolve(profile, "DevToolsActivePort");
for (let i = 0; i < 200; i += 1) {
try {
const [port] = (await fs.readFile(portFile, "utf8")).trim().split(/\r?\n/);
if (port) {
return port;
}
} catch {
// Wait for Chromium to publish the DevTools endpoint.
}
await sleep(50);
}
throw new Error("missing DevToolsActivePort");
}
function call(ws, id, method, params = {}) {
ws.send(JSON.stringify({ id, method, params }));
}
function sendCommand(ws, pending, id, method, params = {}, timeoutMs = 15000) {
call(ws, id, method, params);
return Promise.race([
new Promise((resolve) => pending.set(id, resolve)),
new Promise((_, reject) => setTimeout(() => reject(new Error(`${method} timed out`)), timeoutMs)),
]).finally(() => pending.delete(id));
}
async function evaluateArtifact(webSocketDebuggerUrl) {
const ws = new WebSocket(webSocketDebuggerUrl);
let nextId = 1;
const pending = new Map();
ws.addEventListener("message", (event) => {
const message = JSON.parse(event.data);
const waiter = pending.get(message.id);
if (waiter) {
pending.delete(message.id);
waiter(message);
}
});
await new Promise((resolve, reject) => {
ws.addEventListener("open", resolve, { once: true });
ws.addEventListener("error", reject, { once: true });
});
await sendCommand(ws, pending, nextId++, "Page.enable");
await sendCommand(ws, pending, nextId++, "Runtime.enable");
await sendCommand(ws, pending, nextId++, "Page.navigate", { url: targetUrl });
await sleep(500);
const expression = `(
async () => {
for (let i = 0; i < 600; i += 1) {
const api = window.linuxCncRealSimulationApi;
const state = window.linuxCncRealSimulationState;
if (api?.exportDiagnosticsArtifact && state?.summary?.ready) {
const artifact = api.exportDiagnosticsArtifact();
artifact.viewport = { width: window.innerWidth, height: window.innerHeight };
return JSON.stringify(artifact);
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
throw new Error("AXIS diagnostics API did not become ready");
}
)()`;
let lastError = null;
for (let attempt = 0; attempt < 30; attempt += 1) {
const id = nextId;
nextId += 1;
const response = await sendCommand(ws, pending, id, "Runtime.evaluate", {
expression,
awaitPromise: true,
returnByValue: true,
}, 35000);
if (!response.error && !response.result.exceptionDetails) {
ws.close();
return response.result.result.value;
}
lastError = response.error?.message ?? response.result.exceptionDetails?.text ?? "Runtime.evaluate failed";
if (!/context|destroyed|navigate|ready/i.test(lastError)) {
break;
}
await sleep(250);
}
ws.close();
throw new Error(lastError ?? "Runtime.evaluate failed");
}
const port = await readDevToolsPort();
const pages = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json();
const page = pages.find((entry) => entry.type === "page" && entry.webSocketDebuggerUrl);
if (!page) {
throw new Error("missing Chromium page target");
}
const artifactText = await evaluateArtifact(page.webSocketDebuggerUrl);
const artifact = JSON.parse(artifactText);
if (artifact.apiName !== "real-browser-simulation-diagnostics-artifact") {
throw new Error(`unexpected diagnostics artifact ${artifact.apiName}`);
}
if (artifact.preview?.renderer !== "threejs" || Number(artifact.preview?.three?.pathPoints ?? 0) <= 0) {
throw new Error("diagnostics artifact missing Three.js preview state");
}
await fs.writeFile(targetPath, `${JSON.stringify(artifact, null, 2)}\n`);
NODE
local cdp_status=$?
kill "$cdp_pid" 2>/dev/null || true
wait "$cdp_pid" 2>/dev/null || true
if [[ "$cdp_status" -ne 0 || ! -s "$full_diagnostics_json" ]]; then
echo "AXIS full diagnostics artifact missing for $name" >&2
exit 1
fi
fi
python3 - "$diagnostics_json" "$full_diagnostics_json" "$name" "$size" "$bytes" "$query" <<'PY'
import json
import pathlib
import sys
target, full_diagnostics, name, size, bytes_count, query = sys.argv[1:7]
target_path = pathlib.Path(target)
screenshot_path = target_path.with_name(target_path.name.replace("-diagnostics.json", ".png"))
full_diagnostics_path = pathlib.Path(full_diagnostics)
full_artifact = json.loads(full_diagnostics_path.read_text(encoding="utf-8")) if full_diagnostics_path.exists() else {}
pathlib.Path(target).write_text(json.dumps({
"apiName": "real-browser-simulation-axis-screenshot-artifact",
"artifactVersion": 1,
"viewportName": name,
"windowSize": size,
"query": query,
"screenshotBytes": int(bytes_count),
"validatedBy": "browser_real_simulation_page_smoke",
"fullDiagnosticsApiName": full_artifact.get("apiName"),
"fullDiagnosticsPath": str(full_diagnostics_path) if full_artifact else None,
"previewRenderer": full_artifact.get("preview", {}).get("renderer"),
"previewViewMode": full_artifact.get("preview", {}).get("viewMode"),
"threeReady": full_artifact.get("preview", {}).get("three", {}).get("ready") is True,
"threePathPoints": full_artifact.get("preview", {}).get("three", {}).get("pathPoints"),
"screenshotPath": str(screenshot_path),
"diagnosticsPath": str(target_path),
}, indent=2, sort_keys=True) + "\n", encoding="utf-8")
PY
if [[ ! -s "$diagnostics_json" ]]; then
echo "AXIS diagnostics artifact missing for $name" >&2
exit 1
fi
if [[ -n "$ARTIFACT_DIR" ]]; then
cp "$shot" "$ARTIFACT_DIR/axis-${name}.png"
if [[ -s "$full_diagnostics_json" ]]; then
cp "$full_diagnostics_json" "$ARTIFACT_DIR/axis-${name}-full-diagnostics.json"
fi
python3 - "$diagnostics_json" "$ARTIFACT_DIR/axis-${name}-diagnostics.json" "$ARTIFACT_DIR/axis-${name}.png" "$ARTIFACT_DIR/axis-${name}-full-diagnostics.json" <<'PY'
import json
import pathlib
import sys
source, target, screenshot, full_diagnostics = sys.argv[1:5]
artifact = json.loads(pathlib.Path(source).read_text(encoding="utf-8"))
artifact["diagnosticsPath"] = str(pathlib.Path(target))
artifact["screenshotPath"] = str(pathlib.Path(screenshot))
artifact["fullDiagnosticsPath"] = str(pathlib.Path(full_diagnostics)) if pathlib.Path(full_diagnostics).exists() else None
pathlib.Path(target).write_text(json.dumps(artifact, indent=2, sort_keys=True) + "\n", encoding="utf-8")
PY
fi
}
run_case desktop-preview 1280,900
run_case desktop-dro 1280,900 "?axis_right=dro"
run_case mobile-preview 390,920
run_case mobile-mdi 390,920 "?axis_left=mdi"
echo "axis_screenshot_browser_smoke=ok"