mirror of
https://github.com/flash555588/ai-model-workbench.git
synced 2026-07-22 06:56:38 +00:00
2243 lines
98 KiB
JavaScript
2243 lines
98 KiB
JavaScript
import esbuild from "esbuild";
|
|
import { chromium } from "playwright-core";
|
|
import { createServer } from "node:http";
|
|
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
import { existsSync } from "node:fs";
|
|
import { extname, join, resolve } from "node:path";
|
|
import process from "node:process";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const rootDir = resolve(fileURLToPath(new URL("..", import.meta.url)));
|
|
const outDir = join(rootDir, ".tmp", "preview-verify");
|
|
const failureDir = join(rootDir, ".tmp", "preview-failures");
|
|
const bundlePath = join(outDir, "preview.js");
|
|
const shimPath = join(outDir, "obsidian-shim.js");
|
|
const entryPath = join(rootDir, "scripts", "visual-preview-entry.ts");
|
|
const modelPath = parseModelPath();
|
|
const stylesPath = join(rootDir, "styles.css");
|
|
|
|
function parseModelPath() {
|
|
const modelIndex = process.argv.indexOf("--model");
|
|
if (modelIndex >= 0) {
|
|
return resolve(process.argv[modelIndex + 1]);
|
|
}
|
|
return join(rootDir, "models", "rubiks-cube-3x3.glb");
|
|
}
|
|
|
|
function parseMode() {
|
|
const modeIndex = process.argv.indexOf("--mode");
|
|
if (modeIndex >= 0) {
|
|
return process.argv[modeIndex + 1] ?? "basic";
|
|
}
|
|
return "basic";
|
|
}
|
|
|
|
function parseRollout() {
|
|
const rolloutIndex = process.argv.indexOf("--rollout");
|
|
if (rolloutIndex >= 0) {
|
|
return process.argv[rolloutIndex + 1] ?? "babylon-safe";
|
|
}
|
|
return "babylon-safe";
|
|
}
|
|
|
|
function parseAllowWorkbenchThree() {
|
|
return process.argv.includes("--allow-workbench-three");
|
|
}
|
|
|
|
function parseExpectBackend() {
|
|
const backendIndex = process.argv.indexOf("--expect-backend");
|
|
if (backendIndex >= 0) {
|
|
return process.argv[backendIndex + 1] ?? null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function parseRouteOnly() {
|
|
return process.argv.includes("--route-only");
|
|
}
|
|
|
|
function parseExpectWarning() {
|
|
const warningIndex = process.argv.indexOf("--expect-warning");
|
|
if (warningIndex >= 0) {
|
|
return process.argv[warningIndex + 1] ?? null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function parseExpectNoWarnings() {
|
|
return process.argv.includes("--expect-no-warnings");
|
|
}
|
|
|
|
function parseExpectGroupParts() {
|
|
return process.argv.includes("--expect-group-parts");
|
|
}
|
|
|
|
function parseExpectColorFidelity() {
|
|
return process.argv.includes("--expect-color-fidelity");
|
|
}
|
|
|
|
function parseExpectSmallParts() {
|
|
return process.argv.includes("--expect-small-parts");
|
|
}
|
|
|
|
const verifyMode = parseMode();
|
|
const verifyRollout = parseRollout();
|
|
const verifyAllowWorkbenchThree = parseAllowWorkbenchThree();
|
|
const verifyExpectedBackend = parseExpectBackend();
|
|
const verifyRouteOnly = parseRouteOnly();
|
|
const verifyExpectedWarning = parseExpectWarning();
|
|
const verifyExpectNoWarnings = parseExpectNoWarnings();
|
|
const verifyExpectGroupParts = parseExpectGroupParts();
|
|
const verifyExpectColorFidelity = parseExpectColorFidelity();
|
|
const verifyExpectSmallParts = parseExpectSmallParts();
|
|
|
|
const mimeTypes = new Map([
|
|
[".html", "text/html; charset=utf-8"],
|
|
[".js", "text/javascript; charset=utf-8"],
|
|
[".css", "text/css; charset=utf-8"],
|
|
[".glb", "model/gltf-binary"],
|
|
[".gltf", "model/gltf+json"],
|
|
[".stl", "application/sla"],
|
|
[".ply", "application/octet-stream"],
|
|
[".obj", "text/plain"],
|
|
[".wasm", "application/wasm"],
|
|
]);
|
|
|
|
function assert(condition, message) {
|
|
if (!condition) {
|
|
throw new Error(message);
|
|
}
|
|
}
|
|
|
|
function candidateBrowsers() {
|
|
const candidates = [];
|
|
if (process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE) {
|
|
candidates.push(process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE);
|
|
}
|
|
|
|
candidates.push(
|
|
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
|
|
"/usr/bin/google-chrome",
|
|
"/usr/bin/google-chrome-stable",
|
|
"/usr/bin/chromium",
|
|
"/usr/bin/chromium-browser",
|
|
"/usr/bin/microsoft-edge",
|
|
"/usr/bin/microsoft-edge-stable",
|
|
"/snap/bin/chromium",
|
|
);
|
|
|
|
if (process.env.HOME) {
|
|
candidates.push(
|
|
join(process.env.HOME, "Applications", "Google Chrome.app", "Contents", "MacOS", "Google Chrome"),
|
|
join(process.env.HOME, "Applications", "Microsoft Edge.app", "Contents", "MacOS", "Microsoft Edge"),
|
|
join(process.env.HOME, "Applications", "Chromium.app", "Contents", "MacOS", "Chromium"),
|
|
join(process.env.HOME, "Applications", "Brave Browser.app", "Contents", "MacOS", "Brave Browser"),
|
|
);
|
|
}
|
|
|
|
const programFiles = [
|
|
process.env.PROGRAMFILES,
|
|
process.env["PROGRAMFILES(X86)"],
|
|
process.env.LOCALAPPDATA,
|
|
].filter(Boolean);
|
|
|
|
for (const base of programFiles) {
|
|
candidates.push(
|
|
join(base, "Microsoft", "Edge", "Application", "msedge.exe"),
|
|
join(base, "Google", "Chrome", "Application", "chrome.exe"),
|
|
join(base, "Chromium", "Application", "chrome.exe"),
|
|
);
|
|
}
|
|
|
|
return [...new Set(candidates)].filter((file) => existsSync(file));
|
|
}
|
|
|
|
async function buildHarness() {
|
|
await rm(outDir, { recursive: true, force: true });
|
|
await mkdir(outDir, { recursive: true });
|
|
await writeFile(
|
|
shimPath,
|
|
[
|
|
"export const Platform = { isMobile: false };",
|
|
"export class TFile {}",
|
|
"export class TFolder { constructor() { this.children = []; } }",
|
|
"export class Notice {}",
|
|
"export class Plugin {}",
|
|
"const pathShim = {",
|
|
" delimiter: ';',",
|
|
" join(...segments) { return segments.filter(Boolean).join('/').replace(/\\/+/g, '/'); },",
|
|
" normalize(value) { return String(value).replace(/\\\\/g, '/').replace(/\\/+/g, '/'); },",
|
|
" isAbsolute(value) { return /^([A-Za-z]:[\\\\/]|\\/)/.test(String(value)); },",
|
|
" dirname(value) { const normalized = pathShim.normalize(value).replace(/\\/+$/, ''); const index = normalized.lastIndexOf('/'); return index > 0 ? normalized.slice(0, index) : ''; },",
|
|
" basename(value, ext = '') { const name = pathShim.normalize(value).split('/').pop() || ''; return ext && name.endsWith(ext) ? name.slice(0, -ext.length) : name; },",
|
|
" extname(value) { const name = pathShim.basename(value); const index = name.lastIndexOf('.'); return index > 0 ? name.slice(index) : ''; },",
|
|
"};",
|
|
"if (typeof window !== 'undefined' && !window.require) {",
|
|
" window.require = (id) => {",
|
|
" if (id === 'node:path') return pathShim;",
|
|
" if (id === 'node:process') return { platform: 'browser', env: {} };",
|
|
" throw new Error(`Harness module not available: ${id}`);",
|
|
" };",
|
|
"}",
|
|
"export class Component {",
|
|
" constructor() { this.children = []; }",
|
|
" load() {}",
|
|
" unload() { this.children.length = 0; }",
|
|
" addChild(child) { this.children.push(child); return child; }",
|
|
" removeChild(child) { this.children = this.children.filter((entry) => entry !== child); }",
|
|
"}",
|
|
"export const MarkdownRenderer = {",
|
|
" async render(_app, content, el) { el.textContent = content; }",
|
|
"};",
|
|
"if (typeof HTMLElement !== 'undefined' && !HTMLElement.prototype.setCssProps) {",
|
|
" HTMLElement.prototype.setCssProps = function(props) {",
|
|
" for (const [key, value] of Object.entries(props)) this.style.setProperty(key, value);",
|
|
" };",
|
|
"}",
|
|
"if (typeof SVGElement !== 'undefined' && !SVGElement.prototype.setCssProps) {",
|
|
" SVGElement.prototype.setCssProps = function(props) {",
|
|
" for (const [key, value] of Object.entries(props)) this.style.setProperty(key, value);",
|
|
" };",
|
|
"}",
|
|
"",
|
|
].join("\n"),
|
|
"utf8",
|
|
);
|
|
|
|
await esbuild.build({
|
|
entryPoints: [entryPath],
|
|
bundle: true,
|
|
outfile: bundlePath,
|
|
format: "iife",
|
|
target: "es2020",
|
|
sourcemap: "inline",
|
|
loader: {
|
|
".py": "text",
|
|
},
|
|
logLevel: "silent",
|
|
banner: {
|
|
js: "var activeWindow = window;",
|
|
},
|
|
plugins: [
|
|
{
|
|
name: "obsidian-browser-shim",
|
|
setup(build) {
|
|
build.onResolve({ filter: /^obsidian$/ }, () => ({ path: shimPath }));
|
|
},
|
|
},
|
|
],
|
|
});
|
|
}
|
|
|
|
function createStaticServer() {
|
|
const server = createServer(async (request, response) => {
|
|
try {
|
|
const url = new URL(request.url ?? "/", "http://127.0.0.1");
|
|
const pathname = decodeURIComponent(url.pathname);
|
|
let filePath;
|
|
|
|
if (pathname === "/" || pathname === "/index.html") {
|
|
response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
response.end(`<!doctype html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<link rel="stylesheet" href="/styles.css" />
|
|
<style>
|
|
html, body { margin: 0; background: #101217; color: #f6f0dd; font-family: sans-serif; }
|
|
.scroll-sentinel { height: 900px; display: grid; place-items: center; }
|
|
.preview-card { width: 960px; max-width: calc(100vw - 40px); margin: 0 auto; padding: 20px; background: #171b23; border-radius: 20px; }
|
|
.ai3d-preview-host { min-height: 640px; }
|
|
#preview-canvas { display: block; width: 100%; height: 640px; background: #20242e; border-radius: 14px; }
|
|
.grid-preview-card { margin-block: 80px; }
|
|
.grid-code-block-host .ai3d-grid-host { min-height: 300px; }
|
|
.grid-code-block-host canvas { display: block; width: 100%; height: 300px; background: #20242e; border-radius: 14px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<script src="/.tmp/preview-verify/preview.js"></script>
|
|
</body>
|
|
</html>`);
|
|
return;
|
|
}
|
|
|
|
if (pathname.startsWith("/.tmp/preview-verify/")) {
|
|
filePath = join(rootDir, pathname.slice(1));
|
|
} else if (pathname === "/styles.css") {
|
|
filePath = stylesPath;
|
|
} else if (pathname.startsWith("/models/")) {
|
|
filePath = join(rootDir, pathname.slice(1));
|
|
} else {
|
|
response.writeHead(404);
|
|
response.end("Not found");
|
|
return;
|
|
}
|
|
|
|
const data = await readFile(filePath);
|
|
response.writeHead(200, {
|
|
"content-type": mimeTypes.get(extname(filePath)) ?? "application/octet-stream",
|
|
});
|
|
response.end(data);
|
|
} catch (error) {
|
|
response.writeHead(500, { "content-type": "text/plain; charset=utf-8" });
|
|
response.end(error instanceof Error ? error.stack ?? error.message : String(error));
|
|
}
|
|
});
|
|
|
|
return new Promise((resolveServer) => {
|
|
server.listen(0, "127.0.0.1", () => {
|
|
const address = server.address();
|
|
assert(address && typeof address === "object", "Failed to bind verification server");
|
|
resolveServer({
|
|
server,
|
|
url: `http://127.0.0.1:${address.port}/`,
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
async function canvasPixelStatsForLocator(locator) {
|
|
return locator.evaluate((canvas) => {
|
|
const gl = canvas.getContext("webgl2", { preserveDrawingBuffer: true })
|
|
?? canvas.getContext("webgl", { preserveDrawingBuffer: true });
|
|
if (!gl) {
|
|
throw new Error("Canvas WebGL context is unavailable for pixel readback");
|
|
}
|
|
|
|
const width = gl.drawingBufferWidth;
|
|
const height = gl.drawingBufferHeight;
|
|
const pixels = new Uint8Array(width * height * 4);
|
|
gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
|
|
const stepX = Math.max(1, Math.floor(width / 64));
|
|
const stepY = Math.max(1, Math.floor(height / 64));
|
|
let nonBackground = 0;
|
|
let redDominant = 0;
|
|
let greenDominant = 0;
|
|
let blueDominant = 0;
|
|
let samples = 0;
|
|
let min = 255;
|
|
let max = 0;
|
|
|
|
for (let y = 0; y < height; y += stepY) {
|
|
for (let x = 0; x < width; x += stepX) {
|
|
const offset = (y * width + x) * 4;
|
|
const r = pixels[offset];
|
|
const g = pixels[offset + 1];
|
|
const b = pixels[offset + 2];
|
|
const a = pixels[offset + 3];
|
|
const brightness = (r + g + b) / 3;
|
|
min = Math.min(min, brightness);
|
|
max = Math.max(max, brightness);
|
|
if (a > 0 && Math.abs(r - 32) + Math.abs(g - 36) + Math.abs(b - 46) > 18) {
|
|
nonBackground += 1;
|
|
if (r > g * 1.25 && r > b * 1.25) redDominant += 1;
|
|
if (g > r * 1.2 && g > b * 1.2) greenDominant += 1;
|
|
if (b > r * 1.2 && b > g * 1.2) blueDominant += 1;
|
|
}
|
|
samples += 1;
|
|
}
|
|
}
|
|
|
|
return {
|
|
samples,
|
|
nonBackground,
|
|
redDominant,
|
|
greenDominant,
|
|
blueDominant,
|
|
nonBackgroundRatio: nonBackground / samples,
|
|
contrast: max - min,
|
|
};
|
|
});
|
|
}
|
|
|
|
async function canvasPixelStats(page) {
|
|
return canvasPixelStatsForLocator(page.locator("#preview-canvas"));
|
|
}
|
|
|
|
async function verifyCanvasAccessibility(page) {
|
|
const canvas = page.locator("#preview-canvas");
|
|
const metadata = await canvas.evaluate((entry) => ({
|
|
role: entry.getAttribute("role"),
|
|
label: entry.getAttribute("aria-label"),
|
|
labelledBy: entry.getAttribute("aria-labelledby"),
|
|
labelText: entry.getAttribute("aria-labelledby")
|
|
? document.getElementById(entry.getAttribute("aria-labelledby"))?.textContent
|
|
: null,
|
|
shortcuts: entry.getAttribute("aria-keyshortcuts"),
|
|
title: entry.getAttribute("title"),
|
|
tooltip: entry.getAttribute("data-tooltip"),
|
|
tabIndex: entry.tabIndex,
|
|
}));
|
|
|
|
assert(metadata.role === "application", `Preview canvas role was unexpected: ${metadata.role ?? "null"}`);
|
|
assert(metadata.tabIndex === 0, `Preview canvas is not keyboard focusable: tabIndex=${metadata.tabIndex}`);
|
|
assert(
|
|
metadata.label === null,
|
|
`Preview canvas should not expose aria-label hover text: ${metadata.label ?? "null"}`,
|
|
);
|
|
assert(
|
|
typeof metadata.labelledBy === "string" && metadata.labelledBy.length > 0,
|
|
`Preview canvas was missing an aria-labelledby target: ${metadata.labelledBy ?? "null"}`,
|
|
);
|
|
assert(
|
|
typeof metadata.labelText === "string" && metadata.labelText.includes("3D"),
|
|
`Preview canvas label text was missing useful context: ${metadata.labelText ?? "null"}`,
|
|
);
|
|
assert(
|
|
typeof metadata.labelText === "string" &&
|
|
!metadata.labelText.includes("Shortcuts:") &&
|
|
!metadata.labelText.includes("reset view"),
|
|
`Preview canvas label text should not expose hover shortcut help: ${metadata.labelText ?? "null"}`,
|
|
);
|
|
assert(
|
|
typeof metadata.shortcuts === "string" && metadata.shortcuts.includes("R") && metadata.shortcuts.includes("W"),
|
|
`Preview canvas keyboard shortcuts were missing reset/wireframe keys: ${metadata.shortcuts ?? "null"}`,
|
|
);
|
|
assert(
|
|
metadata.title === null,
|
|
`Preview canvas should not expose a native hover tooltip: ${metadata.title ?? "null"}`,
|
|
);
|
|
assert(
|
|
metadata.tooltip === null,
|
|
`Preview canvas should not expose Obsidian tooltip metadata: ${metadata.tooltip ?? "null"}`,
|
|
);
|
|
|
|
await canvas.focus();
|
|
const focused = await page.evaluate(() => document.activeElement === document.querySelector("#preview-canvas"));
|
|
assert(focused, "Preview canvas did not receive keyboard focus");
|
|
}
|
|
|
|
async function readPreviewState(page) {
|
|
try {
|
|
return await page.evaluate(() => window.__ai3dPreviewVerify ?? null);
|
|
} catch (error) {
|
|
return {
|
|
status: "unavailable",
|
|
error: error instanceof Error ? error.message : String(error),
|
|
};
|
|
}
|
|
}
|
|
|
|
const toolbarLabels = {
|
|
reset: "Reset view",
|
|
wireframe: "Toggle wireframe",
|
|
axes: "Toggle orientation axes",
|
|
boundingBox: "Toggle bounding box",
|
|
slice: "Slice model",
|
|
resolution: "Change render scale",
|
|
measurement: "Measure model; focus a part first to limit scope; Alt/Option-click for free pick",
|
|
copyMeasurements: "Copy measurements",
|
|
clearMeasurements: "Clear measurements",
|
|
};
|
|
|
|
async function getToolbarButton(page, label) {
|
|
const button = page.locator(`.ai3d-helper-toolbar button[aria-label="${label}"]`).first();
|
|
try {
|
|
await button.waitFor({ state: "visible", timeout: 1000 });
|
|
} catch {
|
|
// Secondary actions are collapsed by default; expand the "more" menu.
|
|
const more = page.locator(".ai3d-helper-toolbar .ai3d-mobile-more-toggle").first();
|
|
await more.click();
|
|
await button.waitFor({ state: "visible", timeout: 5000 });
|
|
}
|
|
return button;
|
|
}
|
|
|
|
async function readToolbarInteractionState(page) {
|
|
return page.locator(".ai3d-helper-toolbar").first().evaluate((toolbar) => ({
|
|
mode: toolbar.getAttribute("data-ai3d-interaction-mode"),
|
|
activeButtons: Array.from(toolbar.querySelectorAll("button.ai3d-btn-active"))
|
|
.map((button) => button.getAttribute("data-ai3d-action"))
|
|
.filter(Boolean),
|
|
}));
|
|
}
|
|
|
|
async function dispatchCanvasClick(page, clientX, clientY, modifiers = {}) {
|
|
await page.evaluate(({ clientX, clientY, modifiers }) => {
|
|
const canvas = document.querySelector("#preview-canvas");
|
|
if (!(canvas instanceof HTMLCanvasElement)) {
|
|
throw new Error("Preview canvas is unavailable for synthetic click");
|
|
}
|
|
|
|
canvas.dispatchEvent(new PointerEvent("pointerdown", {
|
|
bubbles: true,
|
|
button: 0,
|
|
buttons: 1,
|
|
clientX,
|
|
clientY,
|
|
altKey: modifiers.altKey === true,
|
|
ctrlKey: modifiers.ctrlKey === true,
|
|
metaKey: modifiers.metaKey === true,
|
|
shiftKey: modifiers.shiftKey === true,
|
|
isPrimary: true,
|
|
pointerId: 1,
|
|
pointerType: "mouse",
|
|
}));
|
|
canvas.dispatchEvent(new PointerEvent("pointerup", {
|
|
bubbles: true,
|
|
button: 0,
|
|
buttons: 0,
|
|
clientX,
|
|
clientY,
|
|
altKey: modifiers.altKey === true,
|
|
ctrlKey: modifiers.ctrlKey === true,
|
|
metaKey: modifiers.metaKey === true,
|
|
shiftKey: modifiers.shiftKey === true,
|
|
isPrimary: true,
|
|
pointerId: 1,
|
|
pointerType: "mouse",
|
|
}));
|
|
}, { clientX, clientY, modifiers });
|
|
}
|
|
|
|
async function pickSelectedPartInfo(page, box) {
|
|
const offsets = [
|
|
[0, 0],
|
|
[0.12, -0.12],
|
|
[-0.12, -0.12],
|
|
[0.12, 0.12],
|
|
[-0.12, 0.12],
|
|
];
|
|
|
|
for (const [offsetX, offsetY] of offsets) {
|
|
const clientX = box.x + box.width * (0.5 + offsetX);
|
|
const clientY = box.y + box.height * (0.5 + offsetY);
|
|
await dispatchCanvasClick(page, clientX, clientY);
|
|
await page.waitForTimeout(100);
|
|
const markdown = await page.evaluate(() => window.__ai3dPreview?.exportSelectedPartInfo?.() ?? "");
|
|
if (markdown.includes("Part Info")) {
|
|
return { markdown, clientX, clientY };
|
|
}
|
|
}
|
|
|
|
return { markdown: "", clientX: box.x + box.width / 2, clientY: box.y + box.height / 2 };
|
|
}
|
|
|
|
async function readPickWorldPoint(page, clientX, clientY) {
|
|
return page.evaluate(({ clientX, clientY }) => new Promise((resolve) => {
|
|
const preview = window.__ai3dPreview;
|
|
const canvas = document.querySelector("#preview-canvas");
|
|
if (!preview || !(canvas instanceof HTMLCanvasElement) || typeof preview.onPick !== "function") {
|
|
resolve(null);
|
|
return;
|
|
}
|
|
|
|
let settled = false;
|
|
const settle = (value) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
release();
|
|
resolve(value);
|
|
};
|
|
const release = preview.onPick((result) => {
|
|
const point = preview.getPickWorldPoint?.(result);
|
|
settle(point ? { x: point.x, y: point.y, z: point.z } : null);
|
|
});
|
|
|
|
canvas.dispatchEvent(new PointerEvent("pointerdown", {
|
|
bubbles: true,
|
|
button: 0,
|
|
buttons: 1,
|
|
clientX,
|
|
clientY,
|
|
isPrimary: true,
|
|
pointerId: 2,
|
|
pointerType: "mouse",
|
|
}));
|
|
canvas.dispatchEvent(new PointerEvent("pointerup", {
|
|
bubbles: true,
|
|
button: 0,
|
|
buttons: 0,
|
|
clientX,
|
|
clientY,
|
|
isPrimary: true,
|
|
pointerId: 2,
|
|
pointerType: "mouse",
|
|
}));
|
|
window.setTimeout(() => settle(null), 250);
|
|
}), { clientX, clientY });
|
|
}
|
|
|
|
function worldPointDistance(left, right) {
|
|
return Math.hypot(left.x - right.x, left.y - right.y, left.z - right.z);
|
|
}
|
|
|
|
function parseMarkdownVector(markdown, label, separator) {
|
|
const escapedLabel = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
const match = markdown.match(new RegExp(`\\|\\s*${escapedLabel}\\s*\\|\\s*([^|]+?)\\s*\\|`));
|
|
if (!match) return null;
|
|
const values = match[1].split(separator).map((entry) => Number.parseFloat(entry.trim()));
|
|
if (values.length !== 3 || values.some((value) => !Number.isFinite(value))) return null;
|
|
return { x: values[0], y: values[1], z: values[2] };
|
|
}
|
|
|
|
function parseSelectedPartBounds(markdown) {
|
|
if (!markdown) return null;
|
|
const size = parseMarkdownVector(markdown, "Bounding Size", /\s*x\s*/i);
|
|
const center = parseMarkdownVector(markdown, "Center", /\s*,\s*/);
|
|
if (!size || !center) return null;
|
|
return {
|
|
min: {
|
|
x: center.x - size.x / 2,
|
|
y: center.y - size.y / 2,
|
|
z: center.z - size.z / 2,
|
|
},
|
|
max: {
|
|
x: center.x + size.x / 2,
|
|
y: center.y + size.y / 2,
|
|
z: center.z + size.z / 2,
|
|
},
|
|
};
|
|
}
|
|
|
|
function assertMeasurementPointOnSelectedBounds(point, bounds, label) {
|
|
const tolerance = 0.08;
|
|
const lockedAxes = ["x", "y", "z"].filter((axis) =>
|
|
Math.abs(point[axis] - bounds.min[axis]) <= tolerance ||
|
|
Math.abs(point[axis] - bounds.max[axis]) <= tolerance
|
|
);
|
|
assert(
|
|
lockedAxes.length >= 2,
|
|
`${label} did not snap to a selected-part edge/corner: point=${JSON.stringify(point)}, bounds=${JSON.stringify(bounds)}`,
|
|
);
|
|
}
|
|
|
|
function isPointInsideSelectedBounds(point, bounds) {
|
|
const tolerance = 0.08;
|
|
return ["x", "y", "z"].every((axis) =>
|
|
point[axis] >= bounds.min[axis] - tolerance &&
|
|
point[axis] <= bounds.max[axis] + tolerance
|
|
);
|
|
}
|
|
|
|
function isBoxLikeMeasurementFixture(markdown) {
|
|
return /cubie_|cube/i.test(markdown);
|
|
}
|
|
|
|
function clampClickToBox(box, clientX, clientY) {
|
|
return {
|
|
clientX: Math.min(Math.max(clientX, box.x + 8), box.x + box.width - 8),
|
|
clientY: Math.min(Math.max(clientY, box.y + 8), box.y + box.height - 8),
|
|
};
|
|
}
|
|
|
|
async function projectSelectedBoundsMeasurementPair(page, bounds) {
|
|
return page.evaluate((selectedBounds) => {
|
|
const preview = window.__ai3dPreview;
|
|
const provider = preview?.getAnnotationProvider?.();
|
|
const canvas = document.querySelector("#preview-canvas");
|
|
if (!provider || !(canvas instanceof HTMLCanvasElement)) return null;
|
|
|
|
const rect = canvas.getBoundingClientRect();
|
|
const center = {
|
|
x: (selectedBounds.min.x + selectedBounds.max.x) / 2,
|
|
y: (selectedBounds.min.y + selectedBounds.max.y) / 2,
|
|
z: (selectedBounds.min.z + selectedBounds.max.z) / 2,
|
|
};
|
|
const insetRatio = 0.035;
|
|
const candidates = [];
|
|
for (const x of [selectedBounds.min.x, selectedBounds.max.x]) {
|
|
for (const y of [selectedBounds.min.y, selectedBounds.max.y]) {
|
|
for (const z of [selectedBounds.min.z, selectedBounds.max.z]) {
|
|
const point = {
|
|
x: x + (center.x - x) * insetRatio,
|
|
y: y + (center.y - y) * insetRatio,
|
|
z: z + (center.z - z) * insetRatio,
|
|
};
|
|
const projection = { screenX: 0, screenY: 0, depth: 0 };
|
|
if (!provider.projectWorldPoint(point, projection)) continue;
|
|
if (!Number.isFinite(projection.screenX) || !Number.isFinite(projection.screenY)) continue;
|
|
if (projection.depth < -0.05 || projection.depth > 1.05) continue;
|
|
const clientX = rect.left + projection.screenX;
|
|
const clientY = rect.top + projection.screenY;
|
|
if (clientX < rect.left + 8 || clientX > rect.right - 8 || clientY < rect.top + 8 || clientY > rect.bottom - 8) continue;
|
|
candidates.push({ clientX, clientY, point, depth: projection.depth });
|
|
}
|
|
}
|
|
}
|
|
|
|
let best = null;
|
|
for (let i = 0; i < candidates.length; i++) {
|
|
for (let j = i + 1; j < candidates.length; j++) {
|
|
const left = candidates[i];
|
|
const right = candidates[j];
|
|
const screenDistance = Math.hypot(left.clientX - right.clientX, left.clientY - right.clientY);
|
|
if (screenDistance < 24) continue;
|
|
if (!best || screenDistance > best.screenDistance) {
|
|
best = { first: left, second: right, screenDistance };
|
|
}
|
|
}
|
|
}
|
|
return best ? { first: best.first, second: best.second } : null;
|
|
}, bounds);
|
|
}
|
|
|
|
async function findMeasurementClickPair(page, box, firstPick, selectedBounds = null) {
|
|
const candidates = [
|
|
{ clientX: firstPick.clientX, clientY: firstPick.clientY },
|
|
{ clientX: firstPick.clientX + 20, clientY: firstPick.clientY },
|
|
{ clientX: firstPick.clientX - 20, clientY: firstPick.clientY },
|
|
{ clientX: firstPick.clientX, clientY: firstPick.clientY + 20 },
|
|
{ clientX: firstPick.clientX, clientY: firstPick.clientY - 20 },
|
|
{ clientX: firstPick.clientX + 32, clientY: firstPick.clientY + 24 },
|
|
{ clientX: firstPick.clientX - 32, clientY: firstPick.clientY + 24 },
|
|
{ clientX: firstPick.clientX + 32, clientY: firstPick.clientY - 24 },
|
|
{ clientX: firstPick.clientX - 32, clientY: firstPick.clientY - 24 },
|
|
{ clientX: firstPick.clientX + 64, clientY: firstPick.clientY },
|
|
{ clientX: firstPick.clientX - 64, clientY: firstPick.clientY },
|
|
{ clientX: firstPick.clientX, clientY: firstPick.clientY + 64 },
|
|
{ clientX: firstPick.clientX, clientY: firstPick.clientY - 64 },
|
|
{ clientX: firstPick.clientX + 48, clientY: firstPick.clientY + 48 },
|
|
{ clientX: firstPick.clientX - 48, clientY: firstPick.clientY + 48 },
|
|
{ clientX: firstPick.clientX + 48, clientY: firstPick.clientY - 48 },
|
|
{ clientX: firstPick.clientX - 48, clientY: firstPick.clientY - 48 },
|
|
{ clientX: box.x + box.width * 0.5, clientY: box.y + box.height * 0.5 },
|
|
{ clientX: box.x + box.width * 0.56, clientY: box.y + box.height * 0.5 },
|
|
{ clientX: box.x + box.width * 0.44, clientY: box.y + box.height * 0.5 },
|
|
{ clientX: box.x + box.width * 0.5, clientY: box.y + box.height * 0.56 },
|
|
{ clientX: box.x + box.width * 0.5, clientY: box.y + box.height * 0.44 },
|
|
].map((entry) => clampClickToBox(box, entry.clientX, entry.clientY));
|
|
|
|
const acceptsPoint = (point) => point && (!selectedBounds || isPointInsideSelectedBounds(point, selectedBounds));
|
|
let first = null;
|
|
for (const candidate of candidates) {
|
|
const point = await readPickWorldPoint(page, candidate.clientX, candidate.clientY);
|
|
if (acceptsPoint(point)) {
|
|
first = { ...candidate, point };
|
|
break;
|
|
}
|
|
}
|
|
assert(first, "Could not find a first pick point for measurement verification");
|
|
|
|
for (const candidate of candidates) {
|
|
const point = await readPickWorldPoint(page, candidate.clientX, candidate.clientY);
|
|
if (acceptsPoint(point) && worldPointDistance(point, first.point) > 0.0001) {
|
|
return { first, second: { ...candidate, point } };
|
|
}
|
|
}
|
|
|
|
throw new Error("Could not find two distinct pick points for measurement verification");
|
|
}
|
|
|
|
async function verifyHelperToolbar(page) {
|
|
await page.waitForSelector(".ai3d-helper-toolbar", { timeout: 5000 });
|
|
await page.waitForSelector(".ai3d-zoom-control:not(.is-hidden) .ai3d-zoom-range", { timeout: 5000 });
|
|
|
|
const beforeZoom = await page.evaluate(() => window.__ai3dPreview?.getCameraZoomState?.()?.value ?? null);
|
|
assert(typeof beforeZoom === "number", `Camera zoom state was unavailable: ${beforeZoom}`);
|
|
const targetZoom = beforeZoom > 0.5 ? 0.2 : 0.8;
|
|
const zoomTrackBox = await page.locator(".ai3d-zoom-track").first().boundingBox();
|
|
assert(zoomTrackBox, "Camera zoom track was not visible for drag verification");
|
|
const dragX = zoomTrackBox.x + zoomTrackBox.width / 2;
|
|
const dragY = zoomTrackBox.y + zoomTrackBox.height * (1 - targetZoom);
|
|
await page.mouse.move(dragX, zoomTrackBox.y + zoomTrackBox.height / 2);
|
|
await page.mouse.down();
|
|
await page.mouse.move(dragX, dragY, { steps: 6 });
|
|
await page.mouse.up();
|
|
await page.waitForTimeout(120);
|
|
const afterZoom = await page.evaluate(() => window.__ai3dPreview?.getCameraZoomState?.()?.value ?? null);
|
|
assert(
|
|
typeof afterZoom === "number" && Math.abs(afterZoom - targetZoom) < 0.08,
|
|
`Camera zoom control did not update preview state: before=${beforeZoom}, target=${targetZoom}, after=${afterZoom}`,
|
|
);
|
|
|
|
const verifyToggleButton = async (button, label) => {
|
|
const before = await button.evaluate((entry) => entry.classList.contains("ai3d-btn-active"));
|
|
await button.click();
|
|
await page.waitForTimeout(100);
|
|
const after = await button.evaluate((entry) => entry.classList.contains("ai3d-btn-active"));
|
|
assert(after !== before, `${label} toolbar button did not toggle`);
|
|
};
|
|
|
|
const wireBtn = await getToolbarButton(page, toolbarLabels.wireframe);
|
|
await verifyToggleButton(wireBtn, "Wireframe");
|
|
|
|
const axesBtn = await getToolbarButton(page, toolbarLabels.axes);
|
|
await verifyToggleButton(axesBtn, "Orientation axes");
|
|
|
|
const bboxBtn = await getToolbarButton(page, toolbarLabels.boundingBox);
|
|
await verifyToggleButton(bboxBtn, "Bounding box");
|
|
|
|
const beforeSliceSnapshot = await page.evaluate(() => window.__ai3dPreview?.captureSnapshot?.() ?? "");
|
|
const sliceBtn = await getToolbarButton(page, toolbarLabels.slice);
|
|
const sliceButtonText = ((await sliceBtn.textContent()) ?? "").trim();
|
|
assert(sliceButtonText.includes("Slice"), `Slice toolbar button is not visibly labeled: ${sliceButtonText}`);
|
|
await sliceBtn.click();
|
|
await page.waitForTimeout(150);
|
|
const sliceDetails = page.locator(".ai3d-helper-toolbar > .ai3d-slice-details:not(.is-hidden)").first();
|
|
await sliceDetails.waitFor({ timeout: 5000 });
|
|
let sliceState = await page.evaluate(() => window.__ai3dPreview?.getSliceState?.() ?? null);
|
|
assert(
|
|
sliceState?.active === true &&
|
|
typeof sliceState.offset === "number" &&
|
|
Math.abs(sliceState.offset - 0.5) < 0.001 &&
|
|
sliceState.normal &&
|
|
Math.abs(sliceState.normal.x) < 0.001 &&
|
|
Math.abs(sliceState.normal.y - 1) < 0.001 &&
|
|
Math.abs(sliceState.normal.z) < 0.001 &&
|
|
sliceState.axis === "y" &&
|
|
Math.abs(sliceState.tiltDegrees) < 0.001 &&
|
|
Math.abs(sliceState.rotationDegrees?.x ?? Number.NaN) < 0.001 &&
|
|
Math.abs(sliceState.rotationDegrees?.y ?? Number.NaN) < 0.001 &&
|
|
Math.abs(sliceState.rotationDegrees?.z ?? Number.NaN) < 0.001,
|
|
`Slice mode did not activate with default state: ${JSON.stringify(sliceState)}`,
|
|
);
|
|
assert(
|
|
await sliceBtn.evaluate((entry) => entry.classList.contains("ai3d-btn-active")),
|
|
"Slice toolbar button did not show active state",
|
|
);
|
|
const sliceInteractionState = await readToolbarInteractionState(page);
|
|
assert(
|
|
sliceInteractionState.mode === "slice" && sliceInteractionState.activeButtons.includes("toggle-slice"),
|
|
`Slice activation did not synchronize the shared interaction mode: ${JSON.stringify(sliceInteractionState)}`,
|
|
);
|
|
let sliceOverlayState = await page.evaluate(() => ({
|
|
planes: window.__ai3dPreview?.sliceOverlayPlanes?.length ?? 0,
|
|
lines: window.__ai3dPreview?.sliceOverlayLines?.length ?? 0,
|
|
}));
|
|
assert(
|
|
sliceOverlayState.planes >= 1 && sliceOverlayState.lines >= 2,
|
|
`Slice overlay planes/frames were not created: ${JSON.stringify(sliceOverlayState)}`,
|
|
);
|
|
const planeButtons = await sliceDetails.locator(".ai3d-slice-plane-btn").count();
|
|
const rangeInputs = await sliceDetails.locator(".ai3d-slice-range").count();
|
|
const numericInputs = await sliceDetails.locator("input.ai3d-slice-number-input").count();
|
|
const presetButtons = await sliceDetails.locator(".ai3d-slice-preset-btn").count();
|
|
assert(
|
|
planeButtons === 0 && rangeInputs === 0 && numericInputs === 4 && presetButtons === 0,
|
|
`Slice inspector still exposed axis/progress controls: ${JSON.stringify({ planeButtons, rangeInputs, numericInputs, presetButtons })}`,
|
|
);
|
|
const offsetInput = sliceDetails.locator("input.ai3d-slice-offset-value").first();
|
|
const rotationInputs = sliceDetails.locator("input.ai3d-slice-rotation-input");
|
|
const offsetText = await offsetInput.inputValue();
|
|
const rotationValues = await rotationInputs.evaluateAll((inputs) => inputs.map((input) => input.value));
|
|
assert(
|
|
offsetText === "50" &&
|
|
JSON.stringify(rotationValues) === JSON.stringify(["0", "0", "0"]) &&
|
|
await sliceDetails.locator(".ai3d-slice-mode-btn").count() === 0,
|
|
`Slice numeric controls were incomplete: ${JSON.stringify({ offsetText, rotationValues })}`,
|
|
);
|
|
|
|
await offsetInput.fill("25");
|
|
await offsetInput.press("Enter");
|
|
await page.waitForTimeout(50);
|
|
const positionedState = await page.evaluate(() => window.__ai3dPreview?.getSliceState?.() ?? null);
|
|
assert(
|
|
Math.abs((positionedState?.offset ?? Number.NaN) - 0.25) < 0.001,
|
|
`Slice position input did not update the cutting plane: ${JSON.stringify(positionedState)}`,
|
|
);
|
|
await rotationInputs.nth(0).fill("30");
|
|
await rotationInputs.nth(0).press("Enter");
|
|
await page.waitForTimeout(100);
|
|
const numericState = await page.evaluate(() => window.__ai3dPreview?.getSliceState?.() ?? null);
|
|
assert(
|
|
Math.abs((numericState?.rotationDegrees?.x ?? Number.NaN) - 30) < 0.1 &&
|
|
Math.abs(numericState?.rotationDegrees?.y ?? Number.NaN) < 0.1 &&
|
|
Math.abs(numericState?.rotationDegrees?.z ?? Number.NaN) < 0.1,
|
|
`Slice numeric inputs did not update position and XYZ rotation: ${JSON.stringify(numericState)}`,
|
|
);
|
|
await sliceDetails.locator(".ai3d-slice-reset-btn").click();
|
|
await page.waitForTimeout(100);
|
|
sliceState = await page.evaluate(() => window.__ai3dPreview?.getSliceState?.() ?? null);
|
|
|
|
const sliceOffsetBeforeDrag = sliceState.offset;
|
|
const canvasBox = await page.locator("canvas").first().boundingBox();
|
|
assert(canvasBox, "Preview canvas bounding box was unavailable for slice drag");
|
|
const normalBeforeCameraDrag = { ...sliceState.normal };
|
|
await page.locator("canvas").first().evaluate((canvas) => {
|
|
const rect = canvas.getBoundingClientRect();
|
|
const options = {
|
|
pointerId: 40,
|
|
pointerType: "mouse",
|
|
isPrimary: true,
|
|
clientX: rect.left + 8,
|
|
clientY: rect.top + 8,
|
|
button: 0,
|
|
buttons: 1,
|
|
bubbles: true,
|
|
cancelable: true,
|
|
};
|
|
canvas.dispatchEvent(new PointerEvent("pointerdown", options));
|
|
canvas.dispatchEvent(new PointerEvent("pointermove", {
|
|
...options,
|
|
clientX: rect.left + 70,
|
|
clientY: rect.top + 42,
|
|
}));
|
|
canvas.dispatchEvent(new PointerEvent("pointerup", {
|
|
...options,
|
|
clientX: rect.left + 70,
|
|
clientY: rect.top + 42,
|
|
buttons: 0,
|
|
}));
|
|
});
|
|
await page.waitForTimeout(100);
|
|
sliceState = await page.evaluate(() => window.__ai3dPreview?.getSliceState?.() ?? null);
|
|
const cameraDragNormalDelta = sliceState?.normal
|
|
? Math.hypot(
|
|
sliceState.normal.x - normalBeforeCameraDrag.x,
|
|
sliceState.normal.y - normalBeforeCameraDrag.y,
|
|
sliceState.normal.z - normalBeforeCameraDrag.z,
|
|
)
|
|
: Number.POSITIVE_INFINITY;
|
|
assert(
|
|
Math.abs((sliceState?.offset ?? Number.NaN) - sliceOffsetBeforeDrag) <= 0.0005 && cameraDragNormalDelta <= 0.0005,
|
|
`Dragging away from the gizmo changed the slice: ${JSON.stringify(sliceState)}`,
|
|
);
|
|
const startX = Math.round(canvasBox.x + canvasBox.width * 0.5);
|
|
const startY = Math.round(canvasBox.y + canvasBox.height * 0.5);
|
|
await page.locator("canvas").first().evaluate((canvas, point) => {
|
|
const target = canvas;
|
|
const options = {
|
|
pointerId: 41,
|
|
pointerType: "touch",
|
|
isPrimary: true,
|
|
clientX: point.startX,
|
|
clientY: point.startY,
|
|
button: 0,
|
|
buttons: 1,
|
|
bubbles: true,
|
|
cancelable: true,
|
|
};
|
|
target.dispatchEvent(new PointerEvent("pointerdown", options));
|
|
target.dispatchEvent(new PointerEvent("pointermove", { ...options, clientY: point.startY - 96 }));
|
|
target.dispatchEvent(new PointerEvent("pointerup", { ...options, clientY: point.startY - 96, buttons: 0 }));
|
|
}, { startX, startY });
|
|
await page.waitForTimeout(150);
|
|
sliceState = await page.evaluate(() => window.__ai3dPreview?.getSliceState?.() ?? null);
|
|
assert(
|
|
sliceState?.active === true &&
|
|
Math.abs(sliceState.offset - sliceOffsetBeforeDrag) > 0.01,
|
|
`Touch drag did not move the cutting plane: ${JSON.stringify({ before: sliceOffsetBeforeDrag, after: sliceState })}`,
|
|
);
|
|
const normalBeforeRotate = { ...sliceState.normal };
|
|
const centerBeforeRotate = sliceState.point ? { ...sliceState.point } : null;
|
|
const rotationDragResult = await page.locator("canvas").first().evaluate((canvas) => {
|
|
const preview = window.__ai3dPreview;
|
|
const babylonCamera = preview?.camera && typeof preview.camera.inertialAlphaOffset === "number"
|
|
? preview.camera
|
|
: null;
|
|
if (babylonCamera) {
|
|
preview.applyConfig?.({ scene: { autoRotate: true, autoRotateSpeed: 2 } });
|
|
babylonCamera.inertialAlphaOffset = 0.08;
|
|
babylonCamera.inertialBetaOffset = -0.04;
|
|
babylonCamera.inertialRadiusOffset = 0.03;
|
|
babylonCamera.inertialPanningX = 0.02;
|
|
babylonCamera.inertialPanningY = -0.02;
|
|
}
|
|
const rect = canvas.getBoundingClientRect();
|
|
const centerX = rect.left + rect.width * 0.5;
|
|
const centerY = rect.top + rect.height * 0.5;
|
|
const maxRadius = Math.min(rect.width, rect.height) * 0.46;
|
|
let pointerId = 42;
|
|
for (let radius = 28; radius <= maxRadius; radius += 12) {
|
|
for (let degrees = 0; degrees < 360; degrees += 10) {
|
|
const radians = degrees * Math.PI / 180;
|
|
const clientX = centerX + Math.cos(radians) * radius;
|
|
const clientY = centerY + Math.sin(radians) * radius;
|
|
const options = {
|
|
pointerId: pointerId++,
|
|
pointerType: "touch",
|
|
isPrimary: true,
|
|
clientX,
|
|
clientY,
|
|
button: 0,
|
|
buttons: 1,
|
|
bubbles: true,
|
|
cancelable: true,
|
|
};
|
|
canvas.dispatchEvent(new PointerEvent("pointerdown", options));
|
|
const state = window.__ai3dPreview?.getSliceState?.();
|
|
if (state?.dragging && state.interactionMode === "rotate") {
|
|
const startNormal = { ...state.normal };
|
|
const startPlaneX = window.__ai3dPreview?.slicePlaneX
|
|
? {
|
|
x: window.__ai3dPreview.slicePlaneX.x,
|
|
y: window.__ai3dPreview.slicePlaneX.y,
|
|
z: window.__ai3dPreview.slicePlaneX.z,
|
|
}
|
|
: null;
|
|
const moves = [[96, 0], [-96, 0], [0, 96], [0, -96], [72, 72], [-72, 72], [72, -72], [-72, -72]];
|
|
for (const [dx, dy] of moves) {
|
|
canvas.dispatchEvent(new PointerEvent("pointermove", {
|
|
...options,
|
|
clientX: clientX + dx,
|
|
clientY: clientY + dy,
|
|
}));
|
|
window.__ai3dPreview?.flushSliceDragUpdate?.();
|
|
const movedState = window.__ai3dPreview?.getSliceState?.();
|
|
const normalDelta = movedState?.normal
|
|
? Math.hypot(
|
|
movedState.normal.x - startNormal.x,
|
|
movedState.normal.y - startNormal.y,
|
|
movedState.normal.z - startNormal.z,
|
|
)
|
|
: 0;
|
|
const movedPlaneX = window.__ai3dPreview?.slicePlaneX;
|
|
const planeAxisDelta = startPlaneX && movedPlaneX
|
|
? Math.hypot(
|
|
movedPlaneX.x - startPlaneX.x,
|
|
movedPlaneX.y - startPlaneX.y,
|
|
movedPlaneX.z - startPlaneX.z,
|
|
)
|
|
: 0;
|
|
if (Math.max(normalDelta, planeAxisDelta) > 0.02) {
|
|
const stableNormal = { ...movedState.normal };
|
|
const stablePlaneX = movedPlaneX
|
|
? { x: movedPlaneX.x, y: movedPlaneX.y, z: movedPlaneX.z }
|
|
: null;
|
|
canvas.dispatchEvent(new PointerEvent("pointermove", {
|
|
...options,
|
|
clientX: clientX + dx,
|
|
clientY: clientY + dy,
|
|
}));
|
|
window.__ai3dPreview?.flushSliceDragUpdate?.();
|
|
const repeatedState = window.__ai3dPreview?.getSliceState?.();
|
|
const repeatedDelta = repeatedState?.normal
|
|
? Math.hypot(
|
|
repeatedState.normal.x - stableNormal.x,
|
|
repeatedState.normal.y - stableNormal.y,
|
|
repeatedState.normal.z - stableNormal.z,
|
|
)
|
|
: Number.POSITIVE_INFINITY;
|
|
const repeatedPlaneX = window.__ai3dPreview?.slicePlaneX;
|
|
const repeatedPlaneDelta = stablePlaneX && repeatedPlaneX
|
|
? Math.hypot(
|
|
repeatedPlaneX.x - stablePlaneX.x,
|
|
repeatedPlaneX.y - stablePlaneX.y,
|
|
repeatedPlaneX.z - stablePlaneX.z,
|
|
)
|
|
: 0;
|
|
const cameraStable = !babylonCamera || (
|
|
babylonCamera.inertialAlphaOffset === 0 &&
|
|
babylonCamera.inertialBetaOffset === 0 &&
|
|
babylonCamera.inertialRadiusOffset === 0 &&
|
|
babylonCamera.inertialPanningX === 0 &&
|
|
babylonCamera.inertialPanningY === 0 &&
|
|
preview.sliceAutoRotatePaused === true
|
|
);
|
|
canvas.dispatchEvent(new PointerEvent("pointerup", {
|
|
...options,
|
|
clientX: clientX + dx,
|
|
clientY: clientY + dy,
|
|
buttons: 0,
|
|
}));
|
|
return {
|
|
grabbed: true,
|
|
repeatedDelta: Math.max(repeatedDelta, repeatedPlaneDelta),
|
|
normalDelta,
|
|
planeAxisDelta,
|
|
cameraStable,
|
|
};
|
|
}
|
|
}
|
|
canvas.dispatchEvent(new PointerEvent("pointerup", { ...options, buttons: 0 }));
|
|
continue;
|
|
}
|
|
canvas.dispatchEvent(new PointerEvent("pointerup", { ...options, buttons: 0 }));
|
|
}
|
|
}
|
|
return {
|
|
grabbed: false,
|
|
repeatedDelta: Number.POSITIVE_INFINITY,
|
|
normalDelta: 0,
|
|
planeAxisDelta: 0,
|
|
cameraStable: !babylonCamera,
|
|
};
|
|
});
|
|
await page.evaluate(() => window.__ai3dPreview?.applyConfig?.({ scene: { autoRotate: false } }));
|
|
assert(
|
|
rotationDragResult.grabbed &&
|
|
rotationDragResult.repeatedDelta <= 0.000001 &&
|
|
rotationDragResult.cameraStable === true,
|
|
`Rotation ring was unstable at a fixed pointer position: ${JSON.stringify(rotationDragResult)}`,
|
|
);
|
|
await page.waitForTimeout(150);
|
|
sliceState = await page.evaluate(() => window.__ai3dPreview?.getSliceState?.() ?? null);
|
|
const normalRotationDelta = sliceState?.normal
|
|
? Math.hypot(
|
|
sliceState.normal.x - normalBeforeRotate.x,
|
|
sliceState.normal.y - normalBeforeRotate.y,
|
|
sliceState.normal.z - normalBeforeRotate.z,
|
|
)
|
|
: 0;
|
|
const rotationCenterDelta = sliceState?.point && centerBeforeRotate
|
|
? Math.hypot(
|
|
sliceState.point.x - centerBeforeRotate.x,
|
|
sliceState.point.y - centerBeforeRotate.y,
|
|
sliceState.point.z - centerBeforeRotate.z,
|
|
)
|
|
: Number.POSITIVE_INFINITY;
|
|
assert(
|
|
sliceState?.interactionMode === "rotate" &&
|
|
Math.max(normalRotationDelta, rotationDragResult.planeAxisDelta) > 0.02 &&
|
|
(normalRotationDelta <= 0.02 || sliceState.tiltDegrees > 1) &&
|
|
rotationCenterDelta <= 0.0005,
|
|
`Touch drag did not rotate around the cutting-board center: ${JSON.stringify({ normalBeforeRotate, centerBeforeRotate, rotationCenterDelta, after: sliceState })}`,
|
|
);
|
|
const afterSliceSnapshot = await page.evaluate(() => window.__ai3dPreview?.captureSnapshot?.() ?? "");
|
|
assert(
|
|
beforeSliceSnapshot.startsWith("data:image/png;base64,") &&
|
|
afterSliceSnapshot.startsWith("data:image/png;base64,") &&
|
|
beforeSliceSnapshot !== afterSliceSnapshot,
|
|
"Slice activation did not change the rendered preview snapshot",
|
|
);
|
|
const sliceSummary = (await sliceDetails.locator(".ai3d-slice-summary").textContent()) ?? "";
|
|
assert(sliceSummary.includes("axis ring"), `Slice summary did not reflect gizmo state: ${sliceSummary}`);
|
|
await sliceDetails.locator(".ai3d-slice-reset-btn").click();
|
|
await page.waitForTimeout(100);
|
|
sliceState = await page.evaluate(() => window.__ai3dPreview?.getSliceState?.() ?? null);
|
|
assert(
|
|
sliceState?.active === true &&
|
|
Math.abs(sliceState.offset - 0.5) < 0.001 &&
|
|
Math.abs(sliceState.normal.x) < 0.001 &&
|
|
Math.abs(sliceState.normal.y - 1) < 0.001 &&
|
|
Math.abs(sliceState.normal.z) < 0.001 &&
|
|
sliceState.axis === "y" &&
|
|
Math.abs(sliceState.tiltDegrees) < 0.001 &&
|
|
Math.abs(sliceState.rotationDegrees?.x ?? Number.NaN) < 0.001 &&
|
|
Math.abs(sliceState.rotationDegrees?.y ?? Number.NaN) < 0.001 &&
|
|
Math.abs(sliceState.rotationDegrees?.z ?? Number.NaN) < 0.001,
|
|
`Slice reset did not restore the model-centered world-horizontal plane: ${JSON.stringify(sliceState)}`,
|
|
);
|
|
await sliceBtn.click();
|
|
await page.waitForTimeout(100);
|
|
sliceState = await page.evaluate(() => window.__ai3dPreview?.getSliceState?.() ?? null);
|
|
assert(sliceState?.active === false, `Slice mode did not turn off: ${JSON.stringify(sliceState)}`);
|
|
const idleInteractionState = await readToolbarInteractionState(page);
|
|
assert(
|
|
idleInteractionState.mode === "idle" && !idleInteractionState.activeButtons.includes("toggle-slice"),
|
|
`Slice deactivation did not restore idle interaction state: ${JSON.stringify(idleInteractionState)}`,
|
|
);
|
|
sliceOverlayState = await page.evaluate(() => ({
|
|
planes: window.__ai3dPreview?.sliceOverlayPlanes?.length ?? 0,
|
|
lines: window.__ai3dPreview?.sliceOverlayLines?.length ?? 0,
|
|
}));
|
|
assert(
|
|
sliceOverlayState.planes === 0 && sliceOverlayState.lines === 0,
|
|
`Slice overlay planes/frames were not removed after toggle off: ${JSON.stringify(sliceOverlayState)}`,
|
|
);
|
|
|
|
const resBtn = await getToolbarButton(page, toolbarLabels.resolution);
|
|
const beforeText = (await resBtn.textContent())?.trim();
|
|
await resBtn.click();
|
|
await page.waitForTimeout(100);
|
|
const afterText = (await resBtn.textContent())?.trim();
|
|
assert(
|
|
!!beforeText && !!afterText && beforeText.includes("%") && afterText.includes("%") && beforeText !== afterText,
|
|
`Resolution toolbar button did not cycle value: before=${beforeText ?? "null"}, after=${afterText ?? "null"}`,
|
|
);
|
|
|
|
const resetBtn = await getToolbarButton(page, toolbarLabels.reset);
|
|
await resetBtn.click();
|
|
await page.waitForTimeout(200);
|
|
}
|
|
|
|
async function verifyMeasurementTool(page, box, firstPick) {
|
|
const selectedBounds = parseSelectedPartBounds(firstPick.markdown);
|
|
assert(selectedBounds, `Could not parse selected-part bounds for measurement snap verification: ${firstPick.markdown}`);
|
|
const isBoxLikeFixture = isBoxLikeMeasurementFixture(firstPick.markdown);
|
|
const clickPair = (isBoxLikeFixture ? await projectSelectedBoundsMeasurementPair(page, selectedBounds) : null)
|
|
?? await findMeasurementClickPair(page, box, firstPick, selectedBounds);
|
|
await dispatchCanvasClick(page, firstPick.clientX, firstPick.clientY);
|
|
await page.waitForTimeout(100);
|
|
const restoredSelection = await page.evaluate(() => window.__ai3dPreview?.exportSelectedPartInfo?.() ?? "");
|
|
assert(
|
|
restoredSelection === firstPick.markdown,
|
|
`Measurement verification could not restore the originally selected target: ${JSON.stringify({ expected: firstPick.markdown, actual: restoredSelection })}`,
|
|
);
|
|
const measureBtn = await getToolbarButton(page, toolbarLabels.measurement);
|
|
await measureBtn.click();
|
|
await page.waitForTimeout(100);
|
|
|
|
const active = await page.evaluate(() => window.__ai3dPreview?.isMeasurementActive?.() ?? false);
|
|
assert(active === true, "Measurement mode did not turn on");
|
|
assert(
|
|
await measureBtn.evaluate((entry) => entry.classList.contains("ai3d-btn-active")),
|
|
"Measurement toolbar button did not show active state",
|
|
);
|
|
const measurementInteractionState = await page.evaluate(() => ({
|
|
toolbar: document.querySelector(".ai3d-helper-toolbar")?.getAttribute("data-ai3d-interaction-mode") ?? null,
|
|
focus: window.__ai3dPreview?.isFocusSelectionEnabled?.() ?? false,
|
|
disassembly: window.__ai3dPreview?.isDisassemblyEnabled?.() ?? false,
|
|
slice: window.__ai3dPreview?.isSliceActive?.() ?? false,
|
|
}));
|
|
assert(
|
|
measurementInteractionState.toolbar === "measurement" &&
|
|
measurementInteractionState.focus === false &&
|
|
measurementInteractionState.disassembly === false &&
|
|
measurementInteractionState.slice === false,
|
|
`Measurement activation did not exclude conflicting modes: ${JSON.stringify(measurementInteractionState)}`,
|
|
);
|
|
assert(
|
|
await page.locator("#preview-canvas").evaluate((canvas) => canvas.classList.contains("ai3d-measurement-focus-aggregation")),
|
|
"Measurement activation did not trigger the blue focus aggregation effect",
|
|
);
|
|
|
|
const measurementStrip = page.locator(".ai3d-helper-group-inspect .ai3d-measurement-strip:not(.is-hidden)").first();
|
|
await measurementStrip.waitFor({ timeout: 5000 });
|
|
const pickStartState = await measurementStrip.evaluate((strip) => ({
|
|
value: strip.querySelector(".ai3d-measurement-strip-value")?.textContent ?? "",
|
|
meta: strip.querySelector(".ai3d-measurement-strip-meta")?.textContent ?? "",
|
|
phase: strip.getAttribute("data-ai3d-measurement-phase"),
|
|
}));
|
|
const pickStartPreviewState = await page.evaluate(() => {
|
|
const preview = window.__ai3dPreview;
|
|
const state = preview?.getMeasurementState?.() ?? null;
|
|
const root = preview?.rootObject ?? preview?.rootMesh ?? null;
|
|
const target = preview?.measurementTargetObject ?? preview?.measurementTargetNode ?? null;
|
|
const targetMeshCount = preview?.measurementTargetObject
|
|
? preview.getMeasurementTargetRenderables?.().length ?? 0
|
|
: preview?.measurementTargetMeshes?.length ?? 0;
|
|
return {
|
|
state,
|
|
targetIsRoot: !!root && target === root,
|
|
targetMeshCount,
|
|
totalMeshCount: window.__ai3dPreviewVerify?.summary?.meshCount ?? 0,
|
|
};
|
|
});
|
|
assert(
|
|
pickStartState.value.includes("Pick start") &&
|
|
pickStartState.phase === "ready" &&
|
|
pickStartPreviewState.state?.targetLocked === true &&
|
|
pickStartPreviewState.state?.targetScope === "model" &&
|
|
pickStartPreviewState.targetIsRoot === true &&
|
|
pickStartPreviewState.targetMeshCount === pickStartPreviewState.totalMeshCount,
|
|
`Measurement status did not lock the selected target before the start point: ${JSON.stringify({ strip: pickStartState, state: pickStartPreviewState })}`,
|
|
);
|
|
|
|
await dispatchCanvasClick(page, clickPair.first.clientX, clickPair.first.clientY);
|
|
await page.waitForTimeout(100);
|
|
const pickEndState = await measurementStrip.evaluate((strip) => ({
|
|
value: strip.querySelector(".ai3d-measurement-strip-value")?.textContent ?? "",
|
|
phase: strip.getAttribute("data-ai3d-measurement-phase"),
|
|
}));
|
|
const firstSnapState = await page.evaluate(() => window.__ai3dPreview?.getMeasurementState?.() ?? null);
|
|
assert(
|
|
pickEndState.phase === "picking-end" &&
|
|
(pickEndState.value.includes("Pick end") || pickEndState.value.includes("Snap:")) &&
|
|
["vertex", "edge"].includes(firstSnapState?.snapKind),
|
|
`Measurement did not snap the start point to selected-target geometry: ${JSON.stringify({ strip: pickEndState, state: firstSnapState })}`,
|
|
);
|
|
|
|
await dispatchCanvasClick(page, clickPair.second.clientX, clickPair.second.clientY);
|
|
await page.waitForTimeout(120);
|
|
const records = await page.evaluate(() => window.__ai3dPreview?.getMeasurementRecords?.() ?? []);
|
|
const completedSnapState = await page.evaluate(() => window.__ai3dPreview?.getMeasurementState?.() ?? null);
|
|
|
|
assert(records.length === 1, `Expected one measurement record, got ${JSON.stringify(records)}`);
|
|
assert(records[0].reading.distance > 0, `Measurement distance was not positive: ${JSON.stringify(records[0])}`);
|
|
assert(
|
|
records[0].reading.absDelta.x > 0 || records[0].reading.absDelta.y > 0 || records[0].reading.absDelta.z > 0,
|
|
`Measurement axis deltas were empty: ${JSON.stringify(records[0])}`,
|
|
);
|
|
if (isBoxLikeFixture) {
|
|
assertMeasurementPointOnSelectedBounds(records[0].start, selectedBounds, "Measurement start");
|
|
assertMeasurementPointOnSelectedBounds(records[0].end, selectedBounds, "Measurement end");
|
|
}
|
|
assert(
|
|
completedSnapState?.targetLocked === true && ["vertex", "edge"].includes(completedSnapState?.snapKind),
|
|
`Measurement did not keep the selected target and geometry snap after completion: ${JSON.stringify(completedSnapState)}`,
|
|
);
|
|
const measurementVisualState = await page.evaluate(() => {
|
|
const preview = window.__ai3dPreview;
|
|
const segment = preview?.measurementSegments?.[0] ?? null;
|
|
const line = segment?.line ?? null;
|
|
const geometry = line?.geometry ?? line?._geometry ?? null;
|
|
let lineVertexCount = 0;
|
|
const threePosition = geometry?.getAttribute?.("position");
|
|
if (threePosition && typeof threePosition.count === "number") {
|
|
lineVertexCount = threePosition.count;
|
|
} else {
|
|
const babylonPositions = line?.getVerticesData?.("position") ?? geometry?.getVerticesData?.("position") ?? null;
|
|
if (babylonPositions && typeof babylonPositions.length === "number") {
|
|
lineVertexCount = Math.floor(babylonPositions.length / 3);
|
|
}
|
|
}
|
|
return {
|
|
lineVertexCount,
|
|
lineClass: line?.constructor?.name ?? null,
|
|
hasLabel: !!segment?.label,
|
|
};
|
|
});
|
|
assert(
|
|
measurementVisualState.lineVertexCount >= 14 && measurementVisualState.hasLabel,
|
|
`Measurement overlay did not render as a drafting dimension glyph: ${JSON.stringify(measurementVisualState)}`,
|
|
);
|
|
const detachedMeasurementGroupCount = await page.locator(".ai3d-helper-group-measurement").count();
|
|
assert(detachedMeasurementGroupCount === 0, `Measurement controls should be integrated into inspect tools, found detached groups: ${detachedMeasurementGroupCount}`);
|
|
const measurementStripState = await measurementStrip.evaluate((strip) => ({
|
|
text: strip.textContent ?? "",
|
|
value: strip.querySelector(".ai3d-measurement-strip-value")?.textContent ?? "",
|
|
meta: strip.querySelector(".ai3d-measurement-strip-meta")?.textContent ?? "",
|
|
toolbarActionCount: document.querySelectorAll(".ai3d-helper-toolbar > button.ai3d-measurement-action, .ai3d-helper-group button.ai3d-measurement-action, .ai3d-helper-group button.ai3d-measurement-detail-action").length,
|
|
borderStyle: getComputedStyle(strip).borderStyle,
|
|
phase: strip.getAttribute("data-ai3d-measurement-phase"),
|
|
}));
|
|
assert(measurementStripState.toolbarActionCount === 0, `Measurement actions should not appear as extra toolbar buttons: ${JSON.stringify(measurementStripState)}`);
|
|
assert(
|
|
measurementStripState.value.trim().length > 0 &&
|
|
!measurementStripState.value.includes("Pick start") &&
|
|
!measurementStripState.value.includes("Pick end") &&
|
|
measurementStripState.meta.includes("1"),
|
|
`Measurement readout did not reflect the saved record: ${JSON.stringify(measurementStripState)}`,
|
|
);
|
|
assert(
|
|
measurementStripState.borderStyle === "none",
|
|
`Measurement readout should use the native toolbar surface, not a boxed card: ${JSON.stringify(measurementStripState)}`,
|
|
);
|
|
await measurementStrip.click();
|
|
const legacyDetailsCount = await page.locator(".ai3d-calibrate-panel").count();
|
|
assert(legacyDetailsCount === 0, `Measurement details should not use legacy calibration panel classes: ${legacyDetailsCount}`);
|
|
await page.locator(".ai3d-helper-toolbar > .ai3d-measurement-details:not(.is-hidden)").waitFor({ timeout: 5000 });
|
|
const detachedDetailsCount = await page.locator(".ai3d-measurement-details:not(.is-hidden)").evaluateAll((entries) =>
|
|
entries.filter((entry) => !entry.parentElement?.classList.contains("ai3d-helper-toolbar")).length
|
|
);
|
|
assert(detachedDetailsCount === 0, `Measurement details should be inside the helper toolbar, found detached panels: ${detachedDetailsCount}`);
|
|
const detailActionCount = await page.locator(".ai3d-helper-toolbar > .ai3d-measurement-details:not(.is-hidden) button.ai3d-measurement-detail-action").count();
|
|
assert(detailActionCount === 2, `Measurement detail actions were not available in the docked details row: ${detailActionCount}`);
|
|
|
|
const detailsPanel = page.locator(".ai3d-helper-toolbar > .ai3d-measurement-details:not(.is-hidden)").first();
|
|
const sectionCount = await detailsPanel.locator(".ai3d-measurement-section").count();
|
|
assert(sectionCount >= 3, `Measurement details were not grouped into inspector sections: ${sectionCount}`);
|
|
const unitSelect = detailsPanel.locator("select.ai3d-measurement-detail-select").first();
|
|
await unitSelect.selectOption("cm");
|
|
await page.waitForTimeout(100);
|
|
const referenceInput = detailsPanel.locator("input.ai3d-measurement-reference-input").first();
|
|
const referenceApply = detailsPanel.locator("[data-ai3d-action='calibrate-reference']").first();
|
|
const targetDistance = records[0].reading.distance * 2;
|
|
await referenceInput.fill(String(targetDistance));
|
|
await referenceApply.click();
|
|
await page.waitForTimeout(120);
|
|
const calibrated = await page.evaluate(() => {
|
|
const preview = window.__ai3dPreview;
|
|
return {
|
|
unit: preview?.getMeasurementUnit?.(),
|
|
scale: preview?.getMeasurementScale?.() ?? null,
|
|
bounds: preview?.getMeasurementBounds?.() ?? null,
|
|
rootScale: preview?.rootObject?.scale
|
|
? { x: preview.rootObject.scale.x, y: preview.rootObject.scale.y, z: preview.rootObject.scale.z }
|
|
: preview?.rootMesh?.scaling
|
|
? { x: preview.rootMesh.scaling.x, y: preview.rootMesh.scaling.y, z: preview.rootMesh.scaling.z }
|
|
: null,
|
|
baseRootScale: preview?.measurementBaseRootScale
|
|
? {
|
|
x: preview.measurementBaseRootScale.x,
|
|
y: preview.measurementBaseRootScale.y,
|
|
z: preview.measurementBaseRootScale.z,
|
|
}
|
|
: preview?.measurementBaseRootScaling
|
|
? {
|
|
x: preview.measurementBaseRootScaling.x,
|
|
y: preview.measurementBaseRootScaling.y,
|
|
z: preview.measurementBaseRootScaling.z,
|
|
}
|
|
: null,
|
|
records: preview?.getMeasurementRecords?.() ?? [],
|
|
markdown: preview?.exportMeasurements?.() ?? "",
|
|
};
|
|
});
|
|
assert(calibrated.unit === "cm", `Measurement unit did not update: ${JSON.stringify(calibrated)}`);
|
|
assert(
|
|
calibrated.scale &&
|
|
Math.abs((calibrated.scale.x ?? 0) - 2) < 0.05 &&
|
|
Math.abs((calibrated.scale.y ?? 0) - 2) < 0.05 &&
|
|
Math.abs((calibrated.scale.z ?? 0) - 2) < 0.05,
|
|
`Reference calibration did not apply a uniform model scale: ${JSON.stringify(calibrated)}`,
|
|
);
|
|
if (calibrated.rootScale) {
|
|
for (const axis of ["x", "y", "z"]) {
|
|
const base = calibrated.baseRootScale?.[axis] ?? 1;
|
|
const rootRatio = Math.abs(base) > 0.0001 ? calibrated.rootScale[axis] / base : calibrated.rootScale[axis];
|
|
assert(
|
|
Math.abs(rootRatio - calibrated.scale[axis]) < 0.08,
|
|
`Renderer root was not scaled on ${axis}: ${JSON.stringify(calibrated)}`,
|
|
);
|
|
}
|
|
}
|
|
assert(calibrated.records[0]?.reading.unit === "cm", `Measurement record unit did not update: ${JSON.stringify(calibrated.records)}`);
|
|
assert(
|
|
Math.abs((calibrated.records[0]?.reading.distance ?? 0) - targetDistance) < Math.max(0.001, targetDistance * 0.01),
|
|
`Reference calibration did not update the measured distance: target=${targetDistance}, records=${JSON.stringify(calibrated.records)}`,
|
|
);
|
|
assert(calibrated.markdown.includes("## Measurements"), "Measurement Markdown export missing heading");
|
|
assert(calibrated.markdown.includes("Delta X"), "Measurement Markdown export missing delta columns");
|
|
assert(calibrated.markdown.includes("cm"), `Measurement Markdown export missing calibrated unit: ${calibrated.markdown}`);
|
|
|
|
await measureBtn.click();
|
|
await page.waitForTimeout(100);
|
|
const toggledOff = await page.evaluate(() => ({
|
|
active: window.__ai3dPreview?.isMeasurementActive?.() ?? true,
|
|
records: window.__ai3dPreview?.getMeasurementRecords?.() ?? [],
|
|
}));
|
|
assert(toggledOff.active === false, `Measurement mode did not turn off: ${JSON.stringify(toggledOff)}`);
|
|
assert(toggledOff.records.length === 1, "Completed measurements were cleared when measurement mode toggled off");
|
|
|
|
const copyBtn = page.locator(`.ai3d-helper-toolbar > .ai3d-measurement-details:not(.is-hidden) button[aria-label="${toolbarLabels.copyMeasurements}"]`).first();
|
|
await copyBtn.waitFor({ state: "visible", timeout: 5000 });
|
|
await copyBtn.click();
|
|
await page.waitForTimeout(100);
|
|
const clipboardText = await page.evaluate(() => navigator.clipboard.readText().catch(() => ""));
|
|
assert(clipboardText.includes("## Measurements") && clipboardText.includes("cm"), `Copied measurements were unexpected: ${clipboardText}`);
|
|
|
|
const clearBtn = page.locator(`.ai3d-helper-toolbar > .ai3d-measurement-details:not(.is-hidden) button[aria-label="${toolbarLabels.clearMeasurements}"]`).first();
|
|
await clearBtn.waitFor({ state: "visible", timeout: 5000 });
|
|
await clearBtn.click();
|
|
await page.waitForTimeout(100);
|
|
const cleared = await page.evaluate(() => ({
|
|
active: window.__ai3dPreview?.isMeasurementActive?.() ?? true,
|
|
records: window.__ai3dPreview?.getMeasurementRecords?.() ?? [],
|
|
markdown: window.__ai3dPreview?.exportMeasurements?.() ?? "not-empty",
|
|
}));
|
|
assert(cleared.active === false, "Clearing measurements unexpectedly enabled measurement mode");
|
|
assert(cleared.records.length === 0, `Clear measurements left records behind: ${JSON.stringify(cleared.records)}`);
|
|
assert(cleared.markdown === "", `Clear measurements left Markdown behind: ${cleared.markdown}`);
|
|
const visibleMeasurementStripCount = await page.locator(".ai3d-helper-group-inspect .ai3d-measurement-strip:not(.is-hidden)").count();
|
|
assert(visibleMeasurementStripCount === 0, `Measurement strip stayed visible after clearing records: ${visibleMeasurementStripCount}`);
|
|
|
|
const escTargetMarkdown = await page.evaluate(() => window.__ai3dPreview?.exportSelectedPartInfo?.() ?? "");
|
|
const escTargetBounds = parseSelectedPartBounds(escTargetMarkdown) ?? selectedBounds;
|
|
const escClickPair = (isBoxLikeMeasurementFixture(escTargetMarkdown)
|
|
? await projectSelectedBoundsMeasurementPair(page, escTargetBounds)
|
|
: null) ?? await findMeasurementClickPair(page, box, { ...firstPick, markdown: escTargetMarkdown }, escTargetBounds);
|
|
|
|
await measureBtn.click();
|
|
await page.waitForTimeout(100);
|
|
await dispatchCanvasClick(page, escClickPair.first.clientX, escClickPair.first.clientY, { altKey: true });
|
|
await page.waitForTimeout(100);
|
|
const freePickPending = await page.evaluate(() => ({
|
|
active: window.__ai3dPreview?.isMeasurementActive?.() ?? false,
|
|
records: window.__ai3dPreview?.getMeasurementRecords?.() ?? [],
|
|
state: window.__ai3dPreview?.getMeasurementState?.() ?? null,
|
|
}));
|
|
assert(
|
|
freePickPending.active === true &&
|
|
freePickPending.records.length === 0 &&
|
|
freePickPending.state?.phase === "picking-end" &&
|
|
freePickPending.state?.snapKind === "free",
|
|
`Alt/Option-click did not create a free pending measurement point: ${JSON.stringify(freePickPending)}`,
|
|
);
|
|
await page.locator("#preview-canvas").press("Escape");
|
|
await page.waitForTimeout(100);
|
|
const freePickCancelled = await page.evaluate(() => ({
|
|
active: window.__ai3dPreview?.isMeasurementActive?.() ?? false,
|
|
records: window.__ai3dPreview?.getMeasurementRecords?.() ?? [],
|
|
state: window.__ai3dPreview?.getMeasurementState?.() ?? null,
|
|
}));
|
|
assert(
|
|
freePickCancelled.active === true &&
|
|
freePickCancelled.records.length === 0 &&
|
|
freePickCancelled.state?.phase === "ready" &&
|
|
freePickCancelled.state?.snapKind == null,
|
|
`Cancelling an Alt/Option free pick did not return measurement mode to ready state: ${JSON.stringify(freePickCancelled)}`,
|
|
);
|
|
|
|
let pendingBeforeEsc = null;
|
|
for (const candidate of [escClickPair.first, escClickPair.second]) {
|
|
await dispatchCanvasClick(page, candidate.clientX, candidate.clientY);
|
|
await page.waitForTimeout(100);
|
|
pendingBeforeEsc = await page.evaluate(() => ({
|
|
active: window.__ai3dPreview?.isMeasurementActive?.() ?? false,
|
|
records: window.__ai3dPreview?.getMeasurementRecords?.() ?? [],
|
|
state: window.__ai3dPreview?.getMeasurementState?.() ?? null,
|
|
}));
|
|
if (pendingBeforeEsc.state?.phase === "picking-end") {
|
|
break;
|
|
}
|
|
}
|
|
assert(
|
|
pendingBeforeEsc?.state?.phase === "picking-end",
|
|
`Could not establish a pending measurement before Esc verification: ${JSON.stringify(pendingBeforeEsc)}`,
|
|
);
|
|
await page.locator("#preview-canvas").press("Escape");
|
|
await page.waitForTimeout(100);
|
|
const pendingCancelled = await page.evaluate(() => ({
|
|
active: window.__ai3dPreview?.isMeasurementActive?.() ?? true,
|
|
records: window.__ai3dPreview?.getMeasurementRecords?.() ?? [],
|
|
state: window.__ai3dPreview?.getMeasurementState?.() ?? null,
|
|
}));
|
|
assert(pendingCancelled.active === true, `Esc should cancel the pending endpoint without leaving measurement mode: ${JSON.stringify(pendingCancelled)}`);
|
|
assert(pendingCancelled.records.length === 0, "Cancelling a pending measurement created a record");
|
|
assert(pendingCancelled.state?.phase === "ready", `Pending measurement did not return to ready state: ${JSON.stringify(pendingCancelled)}`);
|
|
assert(pendingCancelled.state?.snapKind == null, `Cancelling a pending measurement left a stale snap label: ${JSON.stringify(pendingCancelled)}`);
|
|
await page.locator("#preview-canvas").press("Escape");
|
|
await page.waitForTimeout(100);
|
|
const escapedOff = await page.evaluate(() => ({
|
|
active: window.__ai3dPreview?.isMeasurementActive?.() ?? true,
|
|
records: window.__ai3dPreview?.getMeasurementRecords?.() ?? [],
|
|
state: window.__ai3dPreview?.getMeasurementState?.() ?? null,
|
|
}));
|
|
assert(escapedOff.active === false && escapedOff.state?.phase === "inactive", `Second Esc did not turn measurement mode off: ${JSON.stringify(escapedOff)}`);
|
|
|
|
const focusedScope = await page.evaluate(() => {
|
|
const preview = window.__ai3dPreview;
|
|
if (!preview?.toggleFocusSelection || !preview?.toggleMeasurement) return { skipped: true };
|
|
const focusEnabled = preview.toggleFocusSelection();
|
|
const focusedTarget = preview.focusedObject ?? preview.focusedNode ?? null;
|
|
const measurementEnabled = preview.toggleMeasurement();
|
|
const measurementTarget = preview.measurementTargetObject ?? preview.measurementTargetNode ?? null;
|
|
const root = preview.rootObject ?? preview.rootMesh ?? null;
|
|
const state = preview.getMeasurementState?.() ?? null;
|
|
const targetMeshCount = preview.measurementTargetObject
|
|
? preview.getMeasurementTargetRenderables?.().length ?? 0
|
|
: preview.measurementTargetMeshes?.length ?? 0;
|
|
if (preview.isMeasurementActive?.()) preview.toggleMeasurement();
|
|
return {
|
|
skipped: false,
|
|
focusEnabled,
|
|
measurementEnabled,
|
|
capturedFocusedTarget: !!focusedTarget && measurementTarget === focusedTarget,
|
|
targetIsRoot: measurementTarget === root,
|
|
targetMeshCount,
|
|
state,
|
|
};
|
|
});
|
|
assert(
|
|
!focusedScope.skipped &&
|
|
focusedScope.focusEnabled === true &&
|
|
focusedScope.measurementEnabled === true &&
|
|
focusedScope.capturedFocusedTarget === true &&
|
|
focusedScope.targetMeshCount >= 1 &&
|
|
(
|
|
(focusedScope.targetIsRoot === false && focusedScope.state?.targetScope === "part") ||
|
|
(focusedScope.targetIsRoot === true && focusedScope.targetMeshCount === 1 && focusedScope.state?.targetScope === "model")
|
|
),
|
|
`Focused measurement did not limit scope to the focused part: ${JSON.stringify(focusedScope)}`,
|
|
);
|
|
}
|
|
|
|
async function verifyReadonlyPinMode(page, state) {
|
|
assert(state?.mode === "readonly-pin", `Expected readonly-pin mode, received ${state?.mode ?? "unknown"}`);
|
|
await page.waitForFunction(() => {
|
|
const verify = window.__ai3dPreviewVerify;
|
|
return verify?.pinCount === 2
|
|
&& verify.pinLabels?.includes("Center Pin")
|
|
&& verify.pinLabels?.includes("Occluded Pin");
|
|
}, null, { timeout: 5000 });
|
|
|
|
const pin = page.locator(".ai3d-annotation-pin", { hasText: "Center Pin" }).first();
|
|
await pin.waitFor({ state: "visible", timeout: 5000 });
|
|
const pinLabel = (await pin.locator(".ai3d-pin-label").textContent()) ?? "";
|
|
assert(pinLabel.includes("Center Pin"), `Readonly pin label was unexpected: ${pinLabel}`);
|
|
assert(await page.locator(".ai3d-pin-delete").count() === 0, "Readonly pin unexpectedly exposed delete controls");
|
|
|
|
await pin.click();
|
|
await page.waitForTimeout(200);
|
|
assert(await page.locator(".ai3d-annotation-editor").count() === 0, "Readonly pin unexpectedly opened editor");
|
|
|
|
await page.waitForFunction(() => {
|
|
const pins = Array.from(document.querySelectorAll(".ai3d-annotation-pin"));
|
|
const pin = pins.find((entry) => entry.textContent?.includes("Occluded Pin"));
|
|
if (!pin?.classList.contains("ai3d-pin-occluded")) return false;
|
|
const styles = getComputedStyle(pin);
|
|
return styles.visibility === "hidden" && styles.pointerEvents === "none" && Number(styles.opacity) === 0;
|
|
}, null, { timeout: 5000 });
|
|
await verifyOcclusionUpdatesWhileRotating(page);
|
|
}
|
|
|
|
async function verifyFocusSelectionAfterExistingPick(page, selectedPartMarkdown) {
|
|
const focusOn = await page.evaluate(() => window.__ai3dPreview?.toggleFocusSelection());
|
|
assert(focusOn === true, "Focus selection did not turn on");
|
|
|
|
const focusedPartMarkdown = await page.evaluate(() => window.__ai3dPreview?.exportSelectedPartInfo?.() ?? "");
|
|
assert(focusedPartMarkdown.includes("Part Info"), "Focus mode did not preserve the existing selected part");
|
|
assert(
|
|
focusedPartMarkdown === selectedPartMarkdown,
|
|
"Focus mode did not align to the previously selected part",
|
|
);
|
|
}
|
|
|
|
async function verifyFocusSelectionBlankClickPreservesFocus(page, box, selectedPartMarkdown) {
|
|
const beforeFocus = await page.evaluate(() => window.__ai3dPreview?.exportSelectedPartInfo?.() ?? "");
|
|
assert(beforeFocus === selectedPartMarkdown, "Focus mode changed selected part before blank-click verification");
|
|
|
|
await dispatchCanvasClick(page, box.x + 12, box.y + 12);
|
|
await page.waitForTimeout(150);
|
|
|
|
const afterFocus = await page.evaluate(() => ({
|
|
markdown: window.__ai3dPreview?.exportSelectedPartInfo?.() ?? "",
|
|
enabled: window.__ai3dPreview?.isFocusSelectionEnabled?.() ?? true,
|
|
}));
|
|
assert(
|
|
afterFocus.markdown === selectedPartMarkdown && afterFocus.enabled === true,
|
|
`Blank click cleared or changed the focused part: ${JSON.stringify(afterFocus)}`,
|
|
);
|
|
}
|
|
|
|
async function verifyOcclusionUpdatesWhileRotating(page) {
|
|
const occludedPin = page.locator(".ai3d-annotation-pin", { hasText: "Occluded Pin" }).first();
|
|
await occludedPin.waitFor({ state: "attached", timeout: 5000 });
|
|
const before = await occludedPin.evaluate((pin) => ({
|
|
left: pin.style.getPropertyValue("--pin-left"),
|
|
top: pin.style.getPropertyValue("--pin-top"),
|
|
occluded: pin.classList.contains("ai3d-pin-occluded"),
|
|
}));
|
|
|
|
const moved = await page.evaluate(async () => {
|
|
const preview = window.__ai3dPreview;
|
|
const provider = preview?.getAnnotationProvider?.();
|
|
if (!preview || !provider || typeof preview.applyConfig !== "function") {
|
|
return { skipped: true };
|
|
}
|
|
|
|
preview.applyConfig({
|
|
models: [{ path: "models/rubiks-cube-3x3.glb" }],
|
|
camera: { position: [-4.8, 3.1, -4.8], lookAt: [0.45, 0.2, 0] },
|
|
});
|
|
|
|
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
|
const projection = { screenX: 0, screenY: 0, depth: 0 };
|
|
const projected = provider.projectWorldPoint({ x: 0, y: 0, z: 0 }, projection);
|
|
return {
|
|
skipped: false,
|
|
projected,
|
|
occluded: provider.isWorldPointOccluded({ x: 0, y: 0, z: 0 }),
|
|
left: `${Math.round(projection.screenX)}px`,
|
|
top: `${Math.round(projection.screenY)}px`,
|
|
};
|
|
});
|
|
assert(!moved.skipped, "Occlusion rotation verification could not access annotation provider");
|
|
assert(moved.projected, "Occlusion rotation verification could not project the occluded pin");
|
|
|
|
await page.waitForFunction((expected) => {
|
|
const pins = Array.from(document.querySelectorAll(".ai3d-annotation-pin"));
|
|
const pin = pins.find((entry) => entry.textContent?.includes("Occluded Pin"));
|
|
if (!(pin instanceof HTMLElement)) return false;
|
|
return pin.style.getPropertyValue("--pin-left") === expected.left
|
|
&& pin.style.getPropertyValue("--pin-top") === expected.top
|
|
&& pin.classList.contains("ai3d-pin-occluded") === expected.occluded;
|
|
}, moved, { timeout: 5000 });
|
|
|
|
const after = await occludedPin.evaluate((pin) => ({
|
|
left: pin.style.getPropertyValue("--pin-left"),
|
|
top: pin.style.getPropertyValue("--pin-top"),
|
|
occluded: pin.classList.contains("ai3d-pin-occluded"),
|
|
}));
|
|
assert(
|
|
before.left !== after.left || before.top !== after.top || before.occluded !== after.occluded,
|
|
"Occluded pin projection/occlusion state did not update after camera rotation",
|
|
);
|
|
const priority = await page.evaluate(() => {
|
|
const pins = Array.from(document.querySelectorAll(".ai3d-annotation-pin"));
|
|
return pins.map((pin) => ({
|
|
text: pin.textContent ?? "",
|
|
zIndex: Number.parseInt(getComputedStyle(pin).zIndex || "0", 10),
|
|
}));
|
|
});
|
|
assert(
|
|
priority.some((pin) => Number.isFinite(pin.zIndex) && pin.zIndex >= 100),
|
|
`Pin visual priority was not applied: ${JSON.stringify(priority)}`,
|
|
);
|
|
}
|
|
|
|
async function verifyThreePerformanceBudgetSnapshot(page, route, performanceSnapshot) {
|
|
if (route?.backend !== "three") return;
|
|
assert(
|
|
typeof performanceSnapshot?.frameBudgetPixelRatioScale === "number",
|
|
`Three performance snapshot is missing frame budget scale: ${JSON.stringify(performanceSnapshot)}`,
|
|
);
|
|
assert(
|
|
typeof performanceSnapshot?.frameBudgetObserverStride === "number",
|
|
`Three performance snapshot is missing observer stride: ${JSON.stringify(performanceSnapshot)}`,
|
|
);
|
|
assert(
|
|
performanceSnapshot?.viewportVisible === true,
|
|
`Three preview should be visible during verification: ${JSON.stringify(performanceSnapshot)}`,
|
|
);
|
|
assert(
|
|
performanceSnapshot?.disposalAudit?.reason,
|
|
`Three disposal audit is missing: ${JSON.stringify(performanceSnapshot)}`,
|
|
);
|
|
assert(
|
|
typeof performanceSnapshot?.renderedFrameCount === "number" && performanceSnapshot.renderedFrameCount > 0,
|
|
`Three performance snapshot is missing rendered frame count: ${JSON.stringify(performanceSnapshot)}`,
|
|
);
|
|
assert(
|
|
typeof performanceSnapshot?.idleFrameSkipCount === "number" && performanceSnapshot.idleFrameSkipCount >= 0,
|
|
`Three performance snapshot is missing idle frame count: ${JSON.stringify(performanceSnapshot)}`,
|
|
);
|
|
assert(
|
|
typeof performanceSnapshot?.averageRenderMs === "number" && performanceSnapshot.averageRenderMs >= 0,
|
|
`Three performance snapshot is missing average frame timing: ${JSON.stringify(performanceSnapshot)}`,
|
|
);
|
|
assert(
|
|
typeof performanceSnapshot?.p95RenderMs === "number" && performanceSnapshot.p95RenderMs >= 0,
|
|
`Three performance snapshot is missing p95 frame timing: ${JSON.stringify(performanceSnapshot)}`,
|
|
);
|
|
assert(
|
|
typeof performanceSnapshot?.maxRenderMs === "number" && performanceSnapshot.maxRenderMs >= 0,
|
|
`Three performance snapshot is missing max frame timing: ${JSON.stringify(performanceSnapshot)}`,
|
|
);
|
|
assert(
|
|
typeof performanceSnapshot?.adaptiveScaleChangeCount === "number" && performanceSnapshot.adaptiveScaleChangeCount >= 0,
|
|
`Three performance snapshot is missing adaptive scale changes: ${JSON.stringify(performanceSnapshot)}`,
|
|
);
|
|
const quality = performanceSnapshot?.qualitySnapshot;
|
|
assert(quality?.backend === "three", `Three quality snapshot is missing: ${JSON.stringify(performanceSnapshot)}`);
|
|
assert(
|
|
quality.supportedFormats?.includes("glb") && quality.supportedFormats?.includes("obj"),
|
|
`Three quality snapshot missing direct formats: ${JSON.stringify(quality)}`,
|
|
);
|
|
assert(
|
|
quality.colorPipeline?.outputColorSpace === "srgb" && quality.colorPipeline?.toneMapping === "NoToneMapping",
|
|
`Three color pipeline drifted: ${JSON.stringify(quality?.colorPipeline)}`,
|
|
);
|
|
assert(
|
|
typeof quality.camera?.nearFarRatio === "number" && quality.camera.nearFarRatio > 1,
|
|
`Three quality snapshot missing camera precision data: ${JSON.stringify(quality)}`,
|
|
);
|
|
assert(
|
|
typeof quality.performance?.renderedFrameCount === "number" && quality.performance.renderedFrameCount > 0,
|
|
`Three quality snapshot missing rendered frame count: ${JSON.stringify(quality)}`,
|
|
);
|
|
assert(
|
|
typeof quality.performance?.averageRenderMs === "number" && quality.performance.averageRenderMs >= 0,
|
|
`Three quality snapshot missing average frame timing: ${JSON.stringify(quality)}`,
|
|
);
|
|
}
|
|
|
|
function verifyColorFidelity(stats) {
|
|
if (!verifyExpectColorFidelity) return;
|
|
assert(stats.redDominant > 4, `Color fixture did not expose enough red-dominant pixels: ${JSON.stringify(stats)}`);
|
|
assert(stats.greenDominant > 4, `Color fixture did not expose enough green-dominant pixels: ${JSON.stringify(stats)}`);
|
|
assert(stats.blueDominant > 4, `Color fixture did not expose enough blue-dominant pixels: ${JSON.stringify(stats)}`);
|
|
}
|
|
|
|
function verifySmallPartsQuality(state) {
|
|
if (!verifyExpectSmallParts) return;
|
|
const quality = state?.qualitySnapshot;
|
|
const parts = Array.isArray(state?.evidence?.parts) ? state.evidence.parts : [];
|
|
assert(state?.summary?.meshCount >= 7, `Small-parts fixture lost meshes: ${JSON.stringify(state?.summary)}`);
|
|
assert(
|
|
quality?.geometry?.smallPartCount >= 5,
|
|
`Three quality snapshot did not detect small parts: ${JSON.stringify(quality)}`,
|
|
);
|
|
assert(
|
|
quality.geometry.smallestPartSpan > 0 && quality.geometry.smallestPartSpan < quality.geometry.modelSpan * 0.05,
|
|
`Smallest-part precision looks wrong: ${JSON.stringify(quality.geometry)}`,
|
|
);
|
|
assert(
|
|
parts.some((part) => String(part?.name ?? "").includes("tiny_screw")),
|
|
`Small part evidence did not include tiny screw meshes: ${JSON.stringify(parts)}`,
|
|
);
|
|
}
|
|
|
|
function verifyGroupedPartsEvidence(state) {
|
|
if (!verifyExpectGroupParts) return;
|
|
const parts = Array.isArray(state?.evidence?.parts) ? state.evidence.parts : [];
|
|
const groupParts = parts.filter((part) => part?.source === "group");
|
|
const meshParts = parts.filter((part) => part?.source === "mesh");
|
|
const componentParts = parts.filter((part) => part?.source === "component");
|
|
const groupNames = new Set(groupParts.map((part) => part.name));
|
|
|
|
assert(groupParts.length >= 2, `Expected at least 2 grouped parts, got ${JSON.stringify(parts)}`);
|
|
assert(groupNames.has("Left Assembly"), `Missing Left Assembly group part: ${JSON.stringify(parts)}`);
|
|
assert(groupNames.has("Right Assembly"), `Missing Right Assembly group part: ${JSON.stringify(parts)}`);
|
|
assert(!groupNames.has("Grouped Parts Fixture"), `Whole-model wrapper was registered as a group part: ${JSON.stringify(parts)}`);
|
|
assert(
|
|
groupParts.every((part) => part.childCount >= 2 && Array.isArray(part.meshNames) && part.meshNames.length >= 2),
|
|
`Grouped parts did not keep child mesh evidence: ${JSON.stringify(groupParts)}`,
|
|
);
|
|
assert(
|
|
meshParts.some((part) => part.name === "Loose Detail"),
|
|
`Ungrouped mesh part was not preserved alongside grouped parts: ${JSON.stringify(parts)}`,
|
|
);
|
|
const taggedComponent = componentParts.find((part) => part.componentId === "FASTENER-M3");
|
|
assert(
|
|
taggedComponent?.occurrenceId === "ASM-ROOT/FASTENER-001"
|
|
&& taggedComponent.partNumber === "DIN912-M3x8"
|
|
&& taggedComponent.componentPath === "ASM-ROOT/FASTENER-001",
|
|
`Tagged GLB component metadata was not preserved: ${JSON.stringify(parts)}`,
|
|
);
|
|
}
|
|
|
|
async function verifyWorkbenchMode(page, state, stats, performanceSnapshot, selectedPartMarkdown) {
|
|
assert(state?.mode === "workbench", `Expected workbench mode, received ${state?.mode ?? "unknown"}`);
|
|
assert(state?.route?.requireWorkbenchFeatures === true, "Workbench route did not require workbench features");
|
|
assert(state?.pinCount === 1, "Workbench annotation provider did not mount initial pin");
|
|
|
|
const result = await page.evaluate(async () => {
|
|
const preview = window.__ai3dPreview;
|
|
const canvas = document.querySelector("#preview-canvas");
|
|
if (!preview || !(canvas instanceof HTMLCanvasElement)) {
|
|
return { missing: true };
|
|
}
|
|
const methods = [
|
|
"getAnnotationProvider",
|
|
"focusWorldPoint",
|
|
"hasAnimations",
|
|
"setRenderQuality",
|
|
"captureSnapshot",
|
|
"getModelEvidence",
|
|
"exportModelInfo",
|
|
"exportSelectedPartInfo",
|
|
];
|
|
const missingMethods = methods.filter((method) => typeof preview[method] !== "function");
|
|
const before = canvas.toDataURL("image/png");
|
|
preview.focusWorldPoint?.({ x: 0.8, y: 0.8, z: 0.8 });
|
|
await new Promise((resolve) => window.setTimeout(resolve, 450));
|
|
const focused = canvas.toDataURL("image/png");
|
|
const snapshot = preview.captureSnapshot?.() ?? "";
|
|
const evidence = preview.getModelEvidence?.() ?? null;
|
|
return {
|
|
missing: false,
|
|
missingMethods,
|
|
focusChanged: before !== focused,
|
|
snapshot,
|
|
evidence,
|
|
modelInfo: preview.exportModelInfo?.("rubiks-cube-3x3.glb") ?? "",
|
|
};
|
|
});
|
|
|
|
assert(!result.missing, "Workbench preview was unavailable");
|
|
assert(result.missingMethods.length === 0, `Workbench preview is missing methods: ${result.missingMethods.join(", ")}`);
|
|
assert(result.focusChanged, "Workbench focusWorldPoint did not change the rendered canvas");
|
|
assert(result.snapshot.startsWith("data:image/png;base64,"), "Workbench snapshot did not return a PNG data URL");
|
|
assert(result.evidence?.parts?.length > 0, `Workbench evidence is missing parts: ${JSON.stringify(result.evidence)}`);
|
|
assert(result.evidence?.summary?.meshCount === state.summary.meshCount, "Workbench evidence summary did not match preview summary");
|
|
assert(result.modelInfo.includes("Model Info"), "Workbench model info export failed");
|
|
const rows = Array.isArray(state.registeredMatchRows) ? state.registeredMatchRows : [];
|
|
const noteRow = rows.find((row) => row.title === "Left Assembly");
|
|
const modelRow = rows.find((row) => row.title === "Right Assembly");
|
|
assert(noteRow?.button === "Note", `Registered match part-note row did not render Note action: ${JSON.stringify(rows)}`);
|
|
assert(
|
|
noteRow.targetPath === "Parts/3D Components/legacy/01 Left Assembly.md",
|
|
`Registered match part-note target path was wrong: ${JSON.stringify(noteRow)}`,
|
|
);
|
|
assert(noteRow.target === "Opens matched part note", `Registered match part-note target copy was wrong: ${JSON.stringify(noteRow)}`);
|
|
assert(noteRow.model === "From legacy grouped parts.gltf", `Registered match source model label was wrong: ${JSON.stringify(noteRow)}`);
|
|
assert(noteRow.disabled === false, `Registered match part-note action was disabled: ${JSON.stringify(noteRow)}`);
|
|
assert(modelRow?.button === "Model", `Registered match source-model row did not render Model action: ${JSON.stringify(rows)}`);
|
|
assert(
|
|
modelRow.targetPath === "models/auto registered parts.gltf",
|
|
`Registered match source-model target path was wrong: ${JSON.stringify(modelRow)}`,
|
|
);
|
|
assert(modelRow.target === "Opens source model", `Registered match source-model target copy was wrong: ${JSON.stringify(modelRow)}`);
|
|
assert(modelRow.disabled === false, `Registered match source-model action was disabled: ${JSON.stringify(modelRow)}`);
|
|
|
|
console.log("Workbench preview verification passed");
|
|
console.log(JSON.stringify({
|
|
mode: verifyMode,
|
|
rendererRollout: verifyRollout,
|
|
allowWorkbenchThree: verifyAllowWorkbenchThree,
|
|
route: state.route,
|
|
summary: state.summary,
|
|
pixelStats: stats,
|
|
performance: performanceSnapshot,
|
|
selectedPart: selectedPartMarkdown,
|
|
}, null, 2));
|
|
}
|
|
|
|
async function verifyDisassemblyDragResponsive(page, pickPoint) {
|
|
const setup = await page.evaluate(() => {
|
|
const preview = window.__ai3dPreview;
|
|
const canvas = document.querySelector("#preview-canvas");
|
|
if (!preview || !(canvas instanceof HTMLCanvasElement) || typeof preview.toggleDisassembly !== "function") {
|
|
return { skipped: true };
|
|
}
|
|
|
|
if (typeof preview.isFocusSelectionEnabled === "function" && preview.isFocusSelectionEnabled()) {
|
|
preview.toggleFocusSelection();
|
|
}
|
|
const enabled = preview.toggleDisassembly();
|
|
return {
|
|
skipped: false,
|
|
enabled,
|
|
before: canvas.toDataURL("image/png"),
|
|
};
|
|
});
|
|
|
|
assert(!setup.skipped, "Disassembly verification could not access the preview");
|
|
assert(setup.enabled === true, "Disassembly mode did not turn on");
|
|
|
|
await page.mouse.move(pickPoint.clientX, pickPoint.clientY);
|
|
await page.mouse.down();
|
|
await page.mouse.move(pickPoint.clientX + 96, pickPoint.clientY + 16, { steps: 6 });
|
|
await page.mouse.up();
|
|
await page.waitForTimeout(120);
|
|
|
|
const result = await page.evaluate(() => {
|
|
const preview = window.__ai3dPreview;
|
|
const canvas = document.querySelector("#preview-canvas");
|
|
if (!preview || !(canvas instanceof HTMLCanvasElement)) {
|
|
return { skipped: true };
|
|
}
|
|
const after = canvas.toDataURL("image/png");
|
|
return {
|
|
skipped: false,
|
|
after,
|
|
};
|
|
});
|
|
|
|
assert(!result.skipped, "Disassembly verification could not read the canvas after drag");
|
|
assert(setup.before !== result.after, "Disassembly drag did not refresh the canvas immediately");
|
|
|
|
const switched = await page.evaluate(() => {
|
|
const preview = window.__ai3dPreview;
|
|
if (!preview || typeof preview.toggleMeasurement !== "function") return { skipped: true };
|
|
const measurement = preview.toggleMeasurement();
|
|
const originals = preview.disassembly?.originals;
|
|
const restored = originals instanceof Map && originals.size > 0 && Array.from(originals.values()).every(({ part, transform }) => {
|
|
const node = part.object ?? part.node;
|
|
if (!node || !transform) return false;
|
|
const positionDelta = Math.hypot(
|
|
node.position.x - transform.position.x,
|
|
node.position.y - transform.position.y,
|
|
node.position.z - transform.position.z,
|
|
);
|
|
const scale = node.scale ?? node.scaling;
|
|
const originalScale = transform.scale ?? transform.scaling;
|
|
const scaleDelta = scale && originalScale
|
|
? Math.hypot(scale.x - originalScale.x, scale.y - originalScale.y, scale.z - originalScale.z)
|
|
: 0;
|
|
const parentMatches = node.parent === transform.parent;
|
|
return positionDelta <= 0.000001 && scaleDelta <= 0.000001 && parentMatches;
|
|
});
|
|
return {
|
|
skipped: false,
|
|
measurement,
|
|
disassembly: preview.isDisassemblyEnabled?.() ?? true,
|
|
restored,
|
|
};
|
|
});
|
|
await page.waitForTimeout(120);
|
|
await page.evaluate(() => {
|
|
const preview = window.__ai3dPreview;
|
|
if (preview?.isMeasurementActive?.()) preview.toggleMeasurement();
|
|
});
|
|
assert(
|
|
!switched.skipped && switched.measurement === true && switched.disassembly === false,
|
|
`Switching from disassembly to measurement did not update linked mode state: ${JSON.stringify(switched)}`,
|
|
);
|
|
assert(
|
|
switched.restored === true,
|
|
"Switching away from disassembly did not restore moved-part transforms",
|
|
);
|
|
}
|
|
|
|
async function waitForCanvasNonBlank(page, locator, label) {
|
|
let lastError = null;
|
|
for (let attempt = 0; attempt < 60; attempt++) {
|
|
try {
|
|
const stats = await canvasPixelStatsForLocator(locator);
|
|
if (stats.nonBackgroundRatio > 0.005 && stats.contrast > 8) {
|
|
return stats;
|
|
}
|
|
lastError = new Error(`${label} still looks blank: ${JSON.stringify(stats)}`);
|
|
} catch (error) {
|
|
lastError = error;
|
|
}
|
|
await page.waitForTimeout(250);
|
|
}
|
|
throw lastError ?? new Error(`${label} did not produce readable pixels`);
|
|
}
|
|
|
|
async function verifyGridMode(page, state, browserMessages) {
|
|
assert(state?.mode === "grid", `Expected grid mode, received ${state?.mode ?? "unknown"}`);
|
|
assert(state.gridBlockCount >= 3, `Expected at least 3 grid blocks, got ${state.gridBlockCount ?? "unknown"}`);
|
|
const canvases = page.locator(".grid-code-block-host canvas");
|
|
const count = await canvases.count();
|
|
assert(count === state.gridBlockCount, `Grid canvas count mismatch: state=${state.gridBlockCount}, dom=${count}`);
|
|
const stats = [];
|
|
for (let index = 0; index < count; index++) {
|
|
const canvas = canvases.nth(index);
|
|
await canvas.scrollIntoViewIfNeeded();
|
|
stats.push(await waitForCanvasNonBlank(page, canvas, `3dgrid canvas ${index + 1}`));
|
|
}
|
|
const contextLostCount = await page.evaluate(() => window.__ai3dPreviewVerify?.gridContextLostCount ?? 0);
|
|
assert(contextLostCount === 0, `3dgrid reported WebGL context loss count=${contextLostCount}`);
|
|
const contextWarnings = browserMessages.filter((message) =>
|
|
/webglcontextlost|too many active webgl contexts|context lost/i.test(message)
|
|
);
|
|
assert(contextWarnings.length === 0, `3dgrid emitted WebGL context warnings: ${contextWarnings.join("\n")}`);
|
|
console.log("3dgrid preview verification passed");
|
|
console.log(JSON.stringify({
|
|
mode: verifyMode,
|
|
gridBlockCount: count,
|
|
pixelStats: stats,
|
|
}, null, 2));
|
|
}
|
|
|
|
async function saveFailureArtifacts(page, browserMessages, error) {
|
|
await mkdir(failureDir, { recursive: true });
|
|
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
const basePath = join(failureDir, `preview-failure-${stamp}`);
|
|
const screenshotPath = `${basePath}.png`;
|
|
const logPath = `${basePath}.txt`;
|
|
let screenshotLine = "Screenshot: not captured";
|
|
let screenshotCaptured = false;
|
|
|
|
if (page && !page.isClosed()) {
|
|
try {
|
|
await page.screenshot({ path: screenshotPath, fullPage: true });
|
|
screenshotLine = `Screenshot: ${screenshotPath}`;
|
|
screenshotCaptured = true;
|
|
} catch (screenshotError) {
|
|
screenshotLine = `Screenshot failed: ${
|
|
screenshotError instanceof Error ? screenshotError.stack ?? screenshotError.message : String(screenshotError)
|
|
}`;
|
|
}
|
|
}
|
|
|
|
const state = page && !page.isClosed() ? await readPreviewState(page) : null;
|
|
const lines = [
|
|
`Error: ${error instanceof Error ? error.stack ?? error.message : String(error)}`,
|
|
screenshotLine,
|
|
"",
|
|
"Preview state:",
|
|
JSON.stringify(state, null, 2),
|
|
"",
|
|
"Browser messages:",
|
|
browserMessages.length > 0 ? browserMessages.join("\n") : "(none)",
|
|
"",
|
|
];
|
|
|
|
await writeFile(logPath, lines.join("\n"), "utf8");
|
|
return { screenshotPath: screenshotCaptured ? screenshotPath : null, logPath };
|
|
}
|
|
|
|
async function verify() {
|
|
assert(existsSync(modelPath), `Missing sample model: ${modelPath}`);
|
|
await buildHarness();
|
|
const { server, url } = await createStaticServer();
|
|
const browsers = candidateBrowsers();
|
|
assert(
|
|
browsers.length > 0,
|
|
"No Chromium browser found. Install Microsoft Edge/Chrome or set PLAYWRIGHT_CHROMIUM_EXECUTABLE.",
|
|
);
|
|
|
|
const browser = await chromium.launch({
|
|
executablePath: browsers[0],
|
|
headless: true,
|
|
});
|
|
|
|
let page = null;
|
|
const browserMessages = [];
|
|
|
|
try {
|
|
page = await browser.newPage({ viewport: { width: 1280, height: 900 } });
|
|
page.on("console", (message) => browserMessages.push(`${message.type()}: ${message.text()}`));
|
|
page.on("pageerror", (error) => browserMessages.push(`pageerror: ${error.stack ?? error.message}`));
|
|
const params = new URLSearchParams();
|
|
if (verifyMode !== "basic") {
|
|
params.set("mode", verifyMode);
|
|
}
|
|
params.set("rollout", verifyRollout);
|
|
if (verifyAllowWorkbenchThree) {
|
|
params.set("allowWorkbenchThree", "1");
|
|
}
|
|
const modelsRoot = `${join(rootDir, "models").replace(/\\/g, "/")}/`;
|
|
const normalizedModelPath = modelPath.replace(/\\/g, "/");
|
|
const modelRef = normalizedModelPath.startsWith(modelsRoot)
|
|
? normalizedModelPath.slice(modelsRoot.length)
|
|
: modelPath.split(/[/\\]/).pop();
|
|
if (modelRef && modelRef !== "rubiks-cube-3x3.glb") {
|
|
params.set("model", modelRef);
|
|
}
|
|
const targetUrl = params.size > 0 ? `${url}?${params.toString()}` : url;
|
|
await page.context().grantPermissions(["clipboard-read", "clipboard-write"], {
|
|
origin: new URL(targetUrl).origin,
|
|
});
|
|
await page.goto(targetUrl, { waitUntil: "commit" });
|
|
await page.waitForFunction(() => !!window.__ai3dPreviewVerify && window.__ai3dPreviewVerify.status !== "loading", null, {
|
|
timeout: 15000,
|
|
});
|
|
|
|
const state = await page.evaluate(() => window.__ai3dPreviewVerify);
|
|
assert(
|
|
state?.status === "ready",
|
|
`Preview failed: ${state?.error ?? "unknown error"}\n${browserMessages.join("\n")}`,
|
|
);
|
|
if (verifyMode === "grid") {
|
|
await verifyGridMode(page, state, browserMessages);
|
|
return;
|
|
}
|
|
assert(state.summary.meshCount > 0, "Model summary reports zero meshes");
|
|
assert(state.summary.triangleCount > 0, "Model summary reports zero triangles");
|
|
assert(state.summary.vertexCount > 0, "Model summary reports zero vertices");
|
|
assert(state.route?.backend === expectedBackend(verifyMode, verifyRollout), `Unexpected route: ${JSON.stringify(state.route)}`);
|
|
const warnings = Array.isArray(state.summary.resourceWarnings) ? state.summary.resourceWarnings : [];
|
|
if (verifyExpectedWarning) {
|
|
assert(
|
|
warnings.some((warning) => String(warning).includes(verifyExpectedWarning)),
|
|
`Expected resource warning '${verifyExpectedWarning}', got ${JSON.stringify(warnings)}`,
|
|
);
|
|
}
|
|
if (verifyExpectNoWarnings) {
|
|
assert(warnings.length === 0, `Expected no resource warnings, got ${JSON.stringify(warnings)}`);
|
|
}
|
|
verifyGroupedPartsEvidence(state);
|
|
verifySmallPartsQuality(state);
|
|
|
|
await page.locator("#preview-canvas").scrollIntoViewIfNeeded();
|
|
await verifyCanvasAccessibility(page);
|
|
await page.waitForTimeout(500);
|
|
const stats = await canvasPixelStats(page);
|
|
assert(stats.nonBackgroundRatio > 0.01, `Canvas looks blank: ${JSON.stringify(stats)}`);
|
|
assert(stats.contrast > 12, `Canvas has too little contrast: ${JSON.stringify(stats)}`);
|
|
verifyColorFidelity(stats);
|
|
const performanceSnapshot = await page.evaluate(() => window.__ai3dPreview?.getPerformanceSnapshot?.() ?? null);
|
|
assert(
|
|
performanceSnapshot?.backend === state.route?.backend,
|
|
`Performance snapshot backend did not match route: ${JSON.stringify(performanceSnapshot)}`,
|
|
);
|
|
await verifyThreePerformanceBudgetSnapshot(page, state.route, performanceSnapshot);
|
|
|
|
if (verifyRouteOnly) {
|
|
console.log("Preview route-only verification passed");
|
|
console.log(JSON.stringify({
|
|
mode: verifyMode,
|
|
rendererRollout: verifyRollout,
|
|
route: state.route,
|
|
summary: state.summary,
|
|
qualitySnapshot: state.qualitySnapshot,
|
|
pixelStats: stats,
|
|
performance: performanceSnapshot,
|
|
}, null, 2));
|
|
return;
|
|
}
|
|
|
|
const beforeScroll = await page.evaluate(() => window.scrollY);
|
|
const box = await page.locator("#preview-canvas").boundingBox();
|
|
assert(box, "Canvas bounding box is unavailable");
|
|
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
|
await page.mouse.wheel(0, 600);
|
|
await page.waitForTimeout(100);
|
|
const afterScroll = await page.evaluate(() => window.scrollY);
|
|
assert(
|
|
Math.abs(afterScroll - beforeScroll) < 2,
|
|
`Wheel over preview scrolled the page: before=${beforeScroll}, after=${afterScroll}`,
|
|
);
|
|
|
|
const selectedPartPick = await pickSelectedPartInfo(page, box);
|
|
const selectedPartMarkdown = selectedPartPick.markdown;
|
|
assert(selectedPartMarkdown.includes("Part Info"), "Selected part info was not exported");
|
|
assert(selectedPartMarkdown.includes("| Triangles |"), "Selected part info is missing triangle count");
|
|
await verifyFocusSelectionAfterExistingPick(page, selectedPartMarkdown);
|
|
await verifyFocusSelectionBlankClickPreservesFocus(page, box, selectedPartMarkdown);
|
|
if (verifyMode === "basic") {
|
|
await verifyDisassemblyDragResponsive(page, {
|
|
clientX: selectedPartPick.clientX,
|
|
clientY: selectedPartPick.clientY,
|
|
});
|
|
await verifyMeasurementTool(page, box, selectedPartPick);
|
|
}
|
|
|
|
if (verifyMode === "readonly-pin") {
|
|
await verifyReadonlyPinMode(page, state);
|
|
await verifyHelperToolbar(page);
|
|
console.log("Readonly pin preview verification passed");
|
|
console.log(JSON.stringify({
|
|
mode: verifyMode,
|
|
rendererRollout: verifyRollout,
|
|
route: state.route,
|
|
summary: state.summary,
|
|
pixelStats: stats,
|
|
performance: performanceSnapshot,
|
|
selectedPart: selectedPartMarkdown,
|
|
}, null, 2));
|
|
return;
|
|
}
|
|
|
|
await verifyHelperToolbar(page);
|
|
|
|
if (verifyMode === "workbench") {
|
|
await verifyWorkbenchMode(page, state, stats, performanceSnapshot, selectedPartMarkdown);
|
|
return;
|
|
}
|
|
|
|
if (verifyMode === "direct-edit") {
|
|
assert(state?.mode === "direct-edit", `Expected direct-edit mode, received ${state?.mode ?? "unknown"}`);
|
|
|
|
const snapshot = await page.evaluate(() => window.__ai3dPreview?.captureSnapshot?.() ?? "");
|
|
assert(snapshot.startsWith("data:image/png;base64,"), "Snapshot capture did not return a PNG data URL");
|
|
|
|
await page.waitForSelector(".ai3d-annotation-editor", { timeout: 5000 });
|
|
const editorBox = await page.locator(".ai3d-annotation-editor").boundingBox();
|
|
assert(editorBox, "Annotation editor did not open");
|
|
assert(
|
|
Math.abs(editorBox.x - selectedPartPick.clientX) < 220 && Math.abs(editorBox.y - selectedPartPick.clientY) < 220,
|
|
`Annotation editor anchored too far from pick point: ${JSON.stringify(editorBox)}`,
|
|
);
|
|
|
|
await page.locator(".ai3d-annotation-editor-input").fill("Phase 2 Pin");
|
|
await page.locator(".ai3d-annotation-editor-confirm").click();
|
|
await page.waitForFunction(() => window.__ai3dPreviewVerify?.pinCount === 1, null, { timeout: 5000 });
|
|
|
|
const pin = page.locator(".ai3d-annotation-pin").first();
|
|
await pin.waitFor({ state: "visible", timeout: 5000 });
|
|
const pinLabel = (await pin.locator(".ai3d-pin-label").textContent()) ?? "";
|
|
assert(pinLabel.includes("Phase 2 Pin"), `Created pin label was unexpected: ${pinLabel}`);
|
|
|
|
await pin.click();
|
|
await page.waitForFunction(() => {
|
|
const input = document.querySelector(".ai3d-annotation-editor-input");
|
|
return input instanceof HTMLInputElement && input.value === "Phase 2 Pin";
|
|
}, null, { timeout: 5000 });
|
|
await page.locator(".ai3d-annotation-editor-input").fill("Updated Pin");
|
|
await page.locator(".ai3d-annotation-editor-confirm").click();
|
|
await page.waitForFunction(() => window.__ai3dPreviewVerify?.pinLabels?.[0] === "Updated Pin", null, { timeout: 5000 });
|
|
|
|
await pin.click();
|
|
await page.waitForSelector(".ai3d-annotation-editor-delete", { timeout: 5000 });
|
|
await page.locator(".ai3d-annotation-editor-delete").click();
|
|
await page.waitForFunction(() => window.__ai3dPreviewVerify?.pinCount === 0, null, { timeout: 5000 });
|
|
|
|
console.log("Direct edit preview verification passed");
|
|
console.log(JSON.stringify({
|
|
mode: verifyMode,
|
|
rendererRollout: verifyRollout,
|
|
route: state.route,
|
|
summary: state.summary,
|
|
pixelStats: stats,
|
|
performance: performanceSnapshot,
|
|
selectedPart: selectedPartMarkdown,
|
|
}, null, 2));
|
|
return;
|
|
}
|
|
|
|
console.log("Preview verification passed");
|
|
console.log(JSON.stringify({
|
|
mode: verifyMode,
|
|
rendererRollout: verifyRollout,
|
|
route: state.route,
|
|
summary: state.summary,
|
|
pixelStats: stats,
|
|
performance: performanceSnapshot,
|
|
selectedPart: selectedPartMarkdown,
|
|
}, null, 2));
|
|
} catch (error) {
|
|
const artifacts = await saveFailureArtifacts(page, browserMessages, error);
|
|
console.error(`Preview failure artifacts saved: ${artifacts.logPath}`);
|
|
if (artifacts.screenshotPath) {
|
|
console.error(`Preview failure screenshot saved: ${artifacts.screenshotPath}`);
|
|
}
|
|
throw error;
|
|
} finally {
|
|
await browser.close();
|
|
server.close();
|
|
}
|
|
}
|
|
|
|
function expectedBackend(mode, rollout) {
|
|
if (verifyExpectedBackend === "three" || verifyExpectedBackend === "babylon") {
|
|
return verifyExpectedBackend;
|
|
}
|
|
if (mode === "workbench" && !verifyAllowWorkbenchThree) {
|
|
return "babylon";
|
|
}
|
|
if (mode === "workbench") {
|
|
return rollout === "babylon-safe" ? "babylon" : "three";
|
|
}
|
|
if (mode === "direct-edit") {
|
|
return rollout === "three-direct-glb" ? "three" : "babylon";
|
|
}
|
|
return rollout === "babylon-safe" ? "babylon" : "three";
|
|
}
|
|
|
|
verify().catch((error) => {
|
|
console.error(error instanceof Error ? error.stack ?? error.message : String(error));
|
|
process.exitCode = 1;
|
|
});
|