Files
workinf_Blender_Wasm/web/tests/fixtures/native-modeling-workflow.ts
mes123456 f3e21c6b98
Some checks failed
M6 deployable RC / quick (push) Has been cancelled
M6 deployable RC / chromium (push) Has been cancelled
M6 deployable RC / release (push) Has been cancelled
chore: checkpoint web Blender migration source, tools and docs
Accumulated uncommitted work: GHOST Web backend, WebGL/GPU web layer,
web workflow engine and protocol, tooling, docs and task records.
Large generated evidence, user-testing archives, build snapshots and
imported test assets remain untracked.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-11-10 11:18:01 +08:00

117 lines
12 KiB
TypeScript

import { expect, type Page } from '@playwright/test';
import { Buffer } from 'node:buffer';
import { pythonReply } from './wasm-console-workflow';
import { finiteLocation } from './console-main-workflow';
import { performNativeAction } from './native-ui-workflow';
import type { WebEngineHistory, WebEngineRequest } from '../../protocol/web-engine';
export async function nativeRequest(page: Page, command: WebEngineRequest['command']) {
const response = await page.evaluate(command => new Promise<any>((resolve, reject) => {
const worker = (window as any).__nativeWorkflowEngine as Worker, requestId = `web-engine-oracle-${crypto.randomUUID()}`;
if (!worker) throw new Error('Native Worker is unavailable');
const listener = ({ data }: MessageEvent) => {
if (data?.requestId !== requestId || typeof data.ok !== 'boolean') return; clearTimeout(timer); worker.removeEventListener('message', listener); const blend = data.result?.blend;
if (blend !== undefined && (!(blend instanceof ArrayBuffer) || blend.byteLength > 64 * 1024 * 1024)) { reject(new Error('Native blend readback must be an ArrayBuffer within 64 MiB')); return; }
const file = data.result?.desktopFiles?.buffer;
if (file !== undefined && (!(file instanceof ArrayBuffer) || file.byteLength > 64 * 1024 * 1024)) { reject(new Error('Native file readback must be an ArrayBuffer within 64 MiB')); return; }
resolve({ ...data, blendBytes: blend === undefined ? undefined : Array.from(new Uint8Array(blend)), fileBytes: file === undefined ? undefined : Array.from(new Uint8Array(file)) });
};
const timer = setTimeout(() => { worker.removeEventListener('message', listener); reject(new Error('Native readback timed out')); }, 60000); worker.addEventListener('message', listener); worker.postMessage({ requestId, command });
}), command);
expect(response.ok, response.error?.message).toBe(true); return response;
}
export async function queryNativeJSON(page: Page, source: string) {
const response = await nativeRequest(page, { type: 'jsonCommand', cmd: 'pythonExec', params: { source: `exec(${JSON.stringify(source)}, {'__name__': '__wbv2_readback__'})`, filename: 'B2MainReadback.py', console: false } });
const reply = await pythonReply(page); expect(reply).not.toBeNull(); expect(reply!.requestId).toBe(response.requestId);
expect(reply!.state, reply!.output).toBe('DONE'); expect(reply!.changed).toBe(false); return { revision: reply!.revision, value: JSON.parse(reply!.output) };
}
export async function readNativeHistory(page: Page): Promise<WebEngineHistory> {
const response = await nativeRequest(page, { type: 'jsonCommand', cmd: 'history', params: {} }), value = response.result?.json;
expect(value !== null && typeof value === 'object' && !Array.isArray(value)).toBe(true); expect(value.schemaVersion).toBe(1);
for (const key of ['revision', 'cursor', 'limit']) { expect(Number.isSafeInteger(value[key])).toBe(true); expect(value[key]).toBeGreaterThanOrEqual(0); }
expect(Array.isArray(value.entries)).toBe(true); for (const entry of value.entries) { expect(Number.isSafeInteger(entry.index)).toBe(true); expect(typeof entry.label).toBe('string'); }
return value;
}
export async function readNativeBlend(page: Page): Promise<Buffer> {
const response = await nativeRequest(page, { type: 'saveBlend' }), bytes = response.blendBytes;
expect(Array.isArray(bytes)).toBe(true); expect(bytes.length).toBeGreaterThan(0); expect(bytes.every((value: unknown) => Number.isInteger(value) && (value as number) >= 0 && (value as number) <= 255)).toBe(true);
return Buffer.from(bytes);
}
export async function readModelingMain(page: Page, name: string) {
const source = [
`import bpy, bmesh, json\no = bpy.data.objects[${JSON.stringify(name)}]`,
"v = bmesh.from_edit_mesh(o.data).verts if o.mode == 'EDIT' else o.data.vertices",
"tool = bpy.context.workspace.tools.from_space_view3d_mode(bpy.context.mode, create=False)",
"print(json.dumps({'name': o.name, 'mode': o.mode, 'rotationMode': o.rotation_mode, 'toolId': tool.idname if tool else None, 'orientation': bpy.context.scene.transform_orientation_slots[0].type,",
"'location': list(o.location), 'rotation': list(o.rotation_euler), 'scale': list(o.scale), 'selected': sorted(x.name for x in bpy.context.scene.objects if x.select_get()), 'objects': sorted(x.name for x in bpy.context.scene.objects),",
"'vertices': [{'index': x.index, 'co': list(x.co), 'world': list(o.matrix_world @ x.co), 'selected': bool(x.select)} for x in v]}))",
].join('\n');
const result = await queryNativeJSON(page, source), value = result.value;
expect(value !== null && typeof value === 'object' && !Array.isArray(value)).toBe(true); expect(value.name).toBe(name); expect(typeof value.mode).toBe('string'); for (const key of ['location', 'rotation', 'scale']) finiteLocation(value[key]);
expect(typeof value.orientation).toBe('string'); expect(typeof value.rotationMode).toBe('string'); expect(value.toolId === null || typeof value.toolId === 'string').toBe(true);
for (const key of ['selected', 'objects']) { expect(Array.isArray(value[key])).toBe(true); expect(value[key].every((item: unknown) => typeof item === 'string')).toBe(true); }
expect(Array.isArray(value.vertices)).toBe(true); for (const vertex of value.vertices) { finiteLocation(vertex.co); finiteLocation(vertex.world); expect(typeof vertex.selected).toBe('boolean'); expect(Number.isSafeInteger(vertex.index)).toBe(true); expect(vertex.index).toBeGreaterThanOrEqual(0); }
expect(new Set(value.vertices.map((vertex: any) => vertex.index)).size).toBe(value.vertices.length); return result;
}
export function expectVector(actual: unknown, expected: unknown) {
finiteLocation(actual); finiteLocation(expected); for (let index = 0; index < 3; index++) expect(actual[index]).toBeCloseTo(expected[index], 5);
}
export function expectModelingData(actual: any, expected: any) {
for (const key of ['name', 'mode', 'rotationMode', 'toolId', 'orientation']) expect(actual[key]).toBe(expected[key]);
for (const key of ['selected', 'objects']) expect(actual[key]).toEqual(expected[key]);
for (const key of ['location', 'rotation', 'scale']) expectVector(actual[key], expected[key]);
expect(actual.vertices).toHaveLength(expected.vertices.length);
for (const vertex of expected.vertices) { const found = actual.vertices.find((item: any) => item.index === vertex.index); expect(found).toBeDefined(); expect(found.selected).toBe(vertex.selected); expectVector(found.co, vertex.co); expectVector(found.world, vertex.world); }
}
export async function transformNative(page: Page, key: 'g' | 'r' | 's', axis: string | null, input: string) {
await page.keyboard.press(key); const started = await waitNativeModal(page, true);
if (axis !== null) await page.keyboard.press(axis); await page.keyboard.type(input);
const confirmStartedAt = Date.now(), confirmed = await performNativeAction(page, { type: 'gpuUI', gpuOperation: 'frame', keyPressed: 13 }, () => page.keyboard.press('Enter'));
const acknowledgedAt = Date.now(), finished = await waitNativeModal(page, false, confirmed.requestId);
return { key, axis, input, started, confirmed, finished, receiptMs: acknowledgedAt - confirmStartedAt, confirmationMs: Date.now() - confirmStartedAt };
}
export async function nativeKeyFrame(page: Page, key: string, keyCode: number) {
expect(Number.isSafeInteger(keyCode)).toBe(true); const reply = await performNativeAction(page, { type: 'gpuUI', gpuOperation: 'frame', keyPressed: keyCode }, () => page.keyboard.press(key));
return waitNativeModal(page, false, reply.requestId);
}
export async function waitModelingMode(page: Page, name: string, mode: 'OBJECT' | 'EDIT') {
let state: Awaited<ReturnType<typeof readModelingMain>> | undefined;
await expect.poll(async () => { state = await readModelingMain(page, name); return { mode: state.value.mode, toolReady: typeof state.value.toolId === 'string' && state.value.toolId.length > 0 }; }, { timeout: 15000 }).toEqual({ mode, toolReady: true });
return state!;
}
export async function projectNearestVertex(page: Page, name: string) {
const source = [
`import bpy, bmesh, json\nfrom bpy_extras.view3d_utils import location_3d_to_region_2d\no = bpy.data.objects[${JSON.stringify(name)}]`,
"area = bpy.context.area\nassert area.type == 'VIEW_3D'\nregion = next(r for r in area.regions if r.type == 'WINDOW')\nview = area.spaces.active.region_3d\neye = view.view_matrix.inverted().translation\npoints = []",
"for vertex in bmesh.from_edit_mesh(o.data).verts:",
" world = o.matrix_world @ vertex.co\n point = location_3d_to_region_2d(region, view, world)",
" if point is not None and 0 < point.x < region.width and 0 < point.y < region.height:\n points.append({'index': vertex.index, 'x': region.x + point.x, 'y': region.y + point.y, 'distance': (world - eye).length_squared})",
"assert points, 'No visible projected vertex'\np = min(points, key=lambda p: p['distance'])\np['window'] = [bpy.context.window.width, bpy.context.window.height]\nprint(json.dumps(p))",
].join('\n');
const { value } = await queryNativeJSON(page, source); expect(value !== null && typeof value === 'object').toBe(true);
for (const field of ['x', 'y', 'distance']) { expect(typeof value[field]).toBe('number'); expect(Number.isFinite(value[field])).toBe(true); } expect(Number.isSafeInteger(value.index)).toBe(true); expect(value.index).toBeGreaterThanOrEqual(0);
expect(Array.isArray(value.window)).toBe(true); expect(value.window).toHaveLength(2); for (const size of value.window) { expect(Number.isSafeInteger(size)).toBe(true); expect(size).toBeGreaterThan(0); }
const bounds = await page.locator('canvas[aria-label="Blender desktop"]').boundingBox(); expect(bounds).not.toBeNull();
return { index: value.index, native: value, point: { x: bounds!.x + value.x * bounds!.width / value.window[0], y: bounds!.y + (1 - value.y / value.window[1]) * bounds!.height } };
}
export async function focusNativeViewport(page: Page) {
const canvas = page.locator('canvas[aria-label="Blender desktop"]');
if (!await canvas.isVisible()) await page.getByRole('button', { name: '桌面', exact: true }).click();
await expect.poll(async () => { const retry = page.getByRole('button', { name: '重试原生桌面', exact: true }); if (await retry.isVisible()) throw new Error(`Native canvas prerequisite failed: ${await retry.locator('..').textContent()}`); return canvas.getAttribute('data-native-display'); }, { timeout: 90000 }).toBe('acknowledged');
const area = await page.evaluate(() => (window as any).__nativeWorkflowAreas?.find((area: any) => area.editor === 'VIEW_3D' && area.regions.some((region: any) => region.kind === 'MAIN' && region.visible))); expect(area).toBeDefined();
const { x, y, width, height } = area.rect; for (const value of [x, y, width, height]) { expect(typeof value).toBe('number'); expect(Number.isFinite(value)).toBe(true); } expect(width).toBeGreaterThan(0); expect(height).toBeGreaterThan(0);
const bounds = await canvas.boundingBox(); expect(bounds).not.toBeNull(); const point = { x: bounds!.x + (x + width / 2) * bounds!.width, y: bounds!.y + (1 - y - height / 2) * bounds!.height };
await expect(canvas).toHaveAttribute('data-native-input-ready', 'true', { timeout: 15000 });
await page.mouse.move(point.x, point.y); await canvas.focus();
await waitNativeModal(page, false); await expect(canvas).toBeFocused(); return { canvas, point };
}
export async function waitNativeModal(page: Page, active: boolean, after?: string) {
let observed: any;
await expect.poll(async () => {
observed = await page.evaluate(() => (window as any).__nativeWorkflowGPU);
if (observed?.attached === false) { const failure = await page.evaluate(() => (window as any).__nativeWorkflowGPUFailure); if (typeof failure?.message === 'string') throw new Error(`Native canvas detached: ${failure.message}`); }
if (typeof observed?.requestId !== 'string' || observed.requestId === after || typeof observed.modalActive !== 'boolean') return null;
for (const key of ['inputDiscarded', 'contextLost']) if (observed[key] !== undefined) { expect(typeof observed[key]).toBe('boolean'); if (observed[key]) return null; }
return { active: observed.modalActive, ready: observed.displayAcknowledged === true, error: typeof observed.error?.message === 'string' ? observed.error.message : null };
}, { timeout: 15000 }).toEqual({ active, ready: true, error: null }); return observed;
}