fix: release v1.0.3 预设渲染与语言搬运监督修复
web-platform-ci / Standalone decision service (no cloud credentials) (push) Has been cancelled
web-platform-ci / TypeScript, lint, unit, build (push) Has been cancelled
web-platform-ci / Playwright E2E (push) Has been cancelled
lekiwi-compatibility / cpu-compatibility (push) Has been cancelled

This commit is contained in:
2026-09-24 17:02:10 +08:00
parent 0d986f60bd
commit 7519309a27
17 changed files with 623 additions and 27 deletions
@@ -30,3 +30,39 @@ test('主工作台:加载预设、语言命名目标及连续自由坐标抓
await expect(page.getByRole('button', { name: '强化学习任务', exact: true })).toBeVisible();
await page.screenshot({ path: info.outputPath('language-workbench.png') });
});
test.describe('预设画布高 DPI 回归', () => {
test.use({ deviceScaleFactor: 2 });
test('主画布不被 DPR 放大裁切,双相机共用高分辨率绘图缓冲', async ({ page }) => {
let inference = 0;
await page.route('**/api/decision/v1/**', async (route) => {
if (/\/(command|plan|decide|test)$/.test(new URL(route.request().url()).pathname)) {
inference++;
await route.abort();
} else await route.continue();
});
await page.goto('/');
await page.getByRole('tab', { name: '控制台', exact: true }).click();
await page.getByRole('button', { name: '机器人语言控制', exact: true }).click();
const panel = page.getByLabel('机器人语言控制', { exact: true });
await panel.getByRole('button', { name: '载入预设场景' }).click();
await expect(panel.getByRole('status')).toContainText('预设已载入并暂停', { timeout: 90000 });
for (const name of ['腕部摄像头', '前置摄像头']) {
await page.getByRole('combobox', { name: '摄像头视角' }).selectOption({ label: name });
const size = await page
.locator('canvas')
.first()
.evaluate((canvas: HTMLCanvasElement) => ({
css: [canvas.clientWidth, canvas.clientHeight],
host: [canvas.parentElement!.clientWidth, canvas.parentElement!.clientHeight],
pixels: [canvas.width, canvas.height],
}));
expect(size.css).toEqual(size.host);
for (let i = 0; i < 2; i++) {
expect(size.pixels[i] / size.css[i]).toBeGreaterThanOrEqual(1.74);
expect(size.pixels[i] / size.css[i]).toBeLessThanOrEqual(2);
}
}
expect(inference).toBe(0);
});
});
+89 -2
View File
@@ -1,9 +1,12 @@
import { expect, test } from '@playwright/test';
import { writeFile } from 'node:fs/promises';
import { loadWebsiteRobot, runWebsiteCommand } from './website.helpers';
import { collectRobotResults, loadWebsiteRobot, runWebsiteCommand } from './website.helpers';
test('显式授权:公网内置模型,前进/转向/抓放,无手填密钥或自动重试', async ({ page }, info) => {
test.skip(process.env.CADWORLD_LIVE_API !== '1', '仅经用户授权后运行,会消耗服务器公共额度');
test.skip(
process.env.CADWORLD_LIVE_API !== '1' || process.env.CADWORLD_LIVE_PRESET === '1',
'仅经用户授权后运行;预设专项测试不重复执行运动测试',
);
test.setTimeout(360000);
const calls = { command: 0, plan: 0, decide: 0, blocked: 0 };
let csrf = '',
@@ -86,3 +89,87 @@ test('显式授权:公网内置模型,前进/转向/抓放,无手填密钥
}
expect(cleanup).toBe(true);
});
test('显式授权:空工作台默认预设指令完成真实模型抓放', async ({ page }, info) => {
test.skip(
process.env.CADWORLD_LIVE_API !== '1' || process.env.CADWORLD_LIVE_PRESET !== '1',
'仅在显式选择预设专项实测时调用付费 API',
);
test.setTimeout(360000);
const calls = { command: 0, plan: 0, decide: 0, blocked: 0 };
let csrf = '',
cleanup = false;
const captures: Promise<void>[] = [];
page.on('response', (response) => {
if (response.url().endsWith('/session') && response.request().method() === 'POST')
captures.push(
response.json().then((value) => {
csrf = value.csrfToken;
}),
);
});
await page.route('**/api/decision/v1/**', async (route) => {
const endpoint = new URL(route.request().url()).pathname.split('/').at(-1);
if (endpoint === 'command' || endpoint === 'plan' || endpoint === 'decide') {
if (calls[endpoint] >= { command: 1, plan: 1, decide: 13 }[endpoint]) {
calls.blocked++;
await route.abort();
return;
}
calls[endpoint]++;
} else if (endpoint === 'test') {
calls.blocked++;
await route.abort();
return;
}
await route.continue();
});
try {
await collectRobotResults(page);
await page.goto('/');
await page.getByRole('tab', { name: '控制台', exact: true }).click();
await page.getByRole('button', { name: '机器人语言控制', exact: true }).click();
const panel = page.getByLabel('机器人语言控制', { exact: true });
await panel.getByRole('button', { name: '载入预设场景' }).click();
await expect(panel.getByRole('status')).toContainText('预设已载入并暂停', { timeout: 120000 });
await expect(panel.getByRole('textbox', { name: '机器人指令' })).toHaveValue('把方块搬到 B 区');
const result = await runWebsiteCommand(page, '把方块搬到 B 区', info, 'live-default-preset');
expect(result.goal).toEqual([0.257, 0.75, 0.128]);
expect(result.transported).toBeGreaterThan(0.7);
expect(result.sample.onGoalSupport).toBe(true);
expect(result.llmCalls).toBe(2);
expect(result.jevCalls).toBe(14);
expect(calls).toEqual({ command: 1, plan: 1, decide: 13, blocked: 0 });
await page.screenshot({ path: info.outputPath('live-default-preset.png') });
} finally {
await Promise.all(captures);
if (csrf)
cleanup = await page
.evaluate(
async (token) =>
(
await fetch('/api/decision/v1/session', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': token },
body: '{}',
})
).status === 200,
csrf,
)
.catch(() => false);
await writeFile(
info.outputPath('live-default-summary.json'),
JSON.stringify(
{
calls,
sessionDestroyed: cleanup,
maximumUpstreamCalls: { llm: 2, jev: 14 },
retries: 0,
},
null,
2,
),
);
}
expect(cleanup).toBe(true);
});
@@ -5,5 +5,10 @@ export default defineConfig(production, {
testMatch: 'website.live.spec.ts',
retries: 0,
timeout: 360_000,
use: { trace: 'off', video: 'off', screenshot: 'off' },
use: {
...(process.env.CADWORLD_LIVE_URL ? { baseURL: process.env.CADWORLD_LIVE_URL } : {}),
trace: 'off',
video: 'off',
screenshot: 'off',
},
});
@@ -3,6 +3,7 @@ import { AgentTaskController, type SkillExecutor } from './AgentTaskController';
import { MockDecisionProvider } from './MockDecisionProvider';
import { PickPlaceEvaluator, type PhysicalSample } from './PhysicalEvidence';
import { SKILLS, type Skill } from './protocol';
import { LANGUAGE_VERSION } from './LanguageScene';
class FakeSkills implements SkillExecutor {
phase: Skill = 'open';
@@ -99,6 +100,27 @@ describe('模型闭环调度(技能为 mock,物理另有真实 WASM 验收
expect(driver.results).toHaveLength(11);
driver.dispose();
});
it('v2 veto remains in terminal telemetry and never authorizes motion', async () => {
const skills = new FakeSkills();
const provider = new MockDecisionProvider();
const normal = provider.decide.bind(provider);
provider.decide = async (...args) => {
const reply = await normal(...args);
reply.value.noul = 'uncertain';
reply.value.reason = 'none';
return reply;
};
const driver = new AgentTaskController(skills, provider, '搬运方块', {
version: LANGUAGE_VERSION,
});
await run(driver);
expect(driver.state).toBe('failed');
expect(driver.error).toContain('模型无法确认下一技能安全');
expect(driver.snapshot().decision).toMatchObject({ noul: 'uncertain', reason: 'none' });
expect(skills.authorized).toEqual([]);
expect(skills.sample.time).toBe(0);
driver.dispose();
});
it('支持可恢复空抓,但跨重规划也不能重置每技能 2 次上限', async () => {
const skills = new FakeSkills();
skills.fault = 'empty_grasp';
@@ -136,7 +136,8 @@ export class AgentTaskController implements AgentDriver {
phase: this.skills.phase,
candidate: this.skills.candidate,
plan: this.plan,
decision: this.lastDecision,
// Preserve even a vetoed response for diagnostics; it never authorizes a skill.
decision: this.supervisor.last,
llmCalls: this.llmCalls,
jevCalls: this.jevCalls,
replans: this.replans,
@@ -23,6 +23,32 @@ describe('Choice / Noul / Score', () => {
new TaskSupervisor().inspect({ ...decision, noul }, ['carry'], LANGUAGE_VERSION, true),
).toThrow('Noul');
});
it.each(['deny', 'uncertain'] as const)(
'explains %s even when upstream reason is none',
(noul) => {
const supervisor = new TaskSupervisor();
const rejected = { ...decision, noul, reason: 'none' };
expect(() => supervisor.inspect(rejected, ['carry'], LANGUAGE_VERSION, true)).toThrow(
noul === 'uncertain' ? '模型无法确认下一技能安全' : '模型拒绝继续',
);
expect(supervisor.last).toEqual(rejected);
expect(() => supervisor.inspect(rejected, ['carry'], LANGUAGE_VERSION, true)).not.toThrow(
'Noul 拦截:none',
);
},
);
it('does not let an allow score override choice/recovery stops', () => {
const supervisor = new TaskSupervisor();
for (const stop of [{ choice: 'stop' }, { recovery: 'stop' }])
expect(() =>
supervisor.inspect(
{ ...decision, ...stop, reason: 'none' },
['carry', 'stop'],
LANGUAGE_VERSION,
true,
),
).toThrow('Jev 请求安全停止:模型拒绝继续,未提供具体原因');
});
it('rejects illegal candidate, missing fields and arbitrary scores', () => {
const s = new TaskSupervisor();
expect(() => s.inspect(decision, ['open'], LANGUAGE_VERSION, true)).toThrow();
@@ -2,6 +2,19 @@ import { LANGUAGE_VERSION } from './LanguageScene';
import { validateJevDecision, type AgentVersion, type JevDecision, type Skill } from './protocol';
export const QUALITY_POINTS = { poor: 0, partial: 25, good: 75, excellent: 100 } as const;
const REASONS = {
unsafe: '检测到不安全因素',
insufficient_evidence: '当前阶段证据不足',
tracking_error: '动作跟踪或对齐异常',
verified_progress: '模型报告已有进展,但仍拒绝继续',
} as const;
function stopReason(decision: JevDecision): string {
const reason = decision.reason;
if (reason && reason !== 'none') return REASONS[reason];
return decision.noul === 'uncertain'
? '模型无法确认下一技能安全,未提供具体原因'
: '模型拒绝继续,未提供具体原因';
}
/** Supervisory advice can veto but cannot grant physical authority. */
export class TaskSupervisor {
last?: JevDecision;
@@ -18,9 +31,9 @@ export class TaskSupervisor {
const decision = validateJevDecision(value, candidates, version);
this.last = decision;
if (version === LANGUAGE_VERSION && decision.noul !== 'allow')
throw new Error(`Noul 拦截:${decision.reason ?? 'insufficient_evidence'}`);
throw new Error(`Noul 拦截(${decision.noul}):${stopReason(decision)}`);
if (decision.choice === 'stop' || decision.recovery === 'stop')
throw new Error('Jev 请求安全停止');
throw new Error(`Jev 请求安全停止:${stopReason(decision)}`);
if (decision.grasp === 'secure' && !secure) throw new Error('Jev 抓持判断与本地物理证据矛盾');
if (
['carry', 'stop-base'].includes(decision.choice) &&
+20 -8
View File
@@ -24,6 +24,7 @@ import type { EditableMapDocument, MapEditorTransformMode } from '../map/editor/
import type { MapEditorDraftPreviewInstance } from '../map/mapSceneDraft';
import { MapEditorLayer } from './MapEditorLayer';
import { ParametricMapPreviewLayer } from './ParametricMapPreviewLayer';
import { filterSurfaceTexture, fitSceneShadow, viewerPixelRatio } from './renderQuality';
export type InteractionMode = 'select' | 'joint' | 'force';
export type ViewerTheme = 'light' | 'dark';
@@ -144,6 +145,7 @@ export class MuJoCoViewer {
private mapEditorLayer: MapEditorLayer;
private grid: THREE.GridHelper;
private hemisphere: THREE.HemisphereLight;
private readonly sun = new THREE.DirectionalLight(0xffffff, 2);
private themeTransition?: {
started: number;
fromBackground: THREE.Color;
@@ -177,8 +179,9 @@ export class MuJoCoViewer {
String(gl.getParameter(debug.UNMASKED_RENDERER_WEBGL)),
)
: false;
this.renderer.setPixelRatio(Math.min(devicePixelRatio, 1.75));
this.renderer.setPixelRatio(viewerPixelRatio(devicePixelRatio, this.softwareRendering));
this.renderer.shadowMap.enabled = true;
this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
this.renderer.outputColorSpace = THREE.SRGBColorSpace;
host.append(this.renderer.domElement);
this.camera.up.set(0, 0, 1);
@@ -209,10 +212,9 @@ export class MuJoCoViewer {
this.scene.background = new THREE.Color(0x0b1220);
this.hemisphere = new THREE.HemisphereLight(0xffffff, 0x223344, 1.3);
this.scene.add(this.hemisphere);
const light = new THREE.DirectionalLight(0xffffff, 2);
light.position.set(4, -3, 7);
light.castShadow = true;
this.scene.add(light);
this.sun.castShadow = true;
fitSceneShadow(this.sun, [0, 0, 0], 2);
this.scene.add(this.sun, this.sun.target);
this.grid = new THREE.GridHelper(20, 40, 0x3b82f6, 0x253047).rotateX(Math.PI / 2);
this.grid.visible = this.displayOptions.showGrid;
this.scene.add(this.grid);
@@ -628,6 +630,7 @@ export class MuJoCoViewer {
this.cameraFocusTransition = undefined;
const { extent, center } = session.geometryBounds();
this.modelExtent = extent;
fitSceneShadow(this.sun, center, extent);
this.controls.target.set(center[0], center[1], center[2]);
this.camera.position.set(
center[0] + extent * 1.5,
@@ -651,10 +654,19 @@ export class MuJoCoViewer {
private resize(): void {
const w = Math.max(1, this.host.clientWidth),
h = Math.max(1, this.host.clientHeight);
if (w === this.resizeWidth && h === this.resizeHeight) return;
const ratio = viewerPixelRatio(devicePixelRatio, this.softwareRendering);
if (
w === this.resizeWidth &&
h === this.resizeHeight &&
ratio === this.renderer.getPixelRatio()
)
return;
this.renderer.setPixelRatio(ratio);
this.resizeWidth = w;
this.resizeHeight = h;
this.renderer.setSize(w, h, false);
// CSS size must stay in logical pixels: otherwise DPR also enlarges/clips the canvas
// and cancels the sensor viewport's resolution gain.
this.renderer.setSize(w, h);
this.camera.aspect = w / h;
this.camera.updateProjectionMatrix();
}
@@ -977,7 +989,7 @@ export class MuJoCoViewer {
found = new THREE.DataTexture(data, w, h, THREE.RGBAFormat);
found.colorSpace = THREE.SRGBColorSpace;
found.flipY = true;
found.needsUpdate = true;
filterSurfaceTexture(found, this.renderer.capabilities.getMaxAnisotropy());
this.textures.set(id, found);
return found;
}
@@ -0,0 +1,72 @@
import * as THREE from 'three';
import { filterSurfaceTexture, fitSceneShadow, viewerPixelRatio } from './renderQuality';
import { MuJoCoViewer } from './MuJoCoViewer';
it('GPU supersampling is bounded; software rendering keeps its existing budget', () => {
expect(viewerPixelRatio(1, false)).toBe(1.5);
expect(viewerPixelRatio(1.75, false)).toBe(1.75);
expect(viewerPixelRatio(3, false)).toBe(2);
expect(viewerPixelRatio(1, true)).toBe(1);
expect(viewerPixelRatio(3, true)).toBe(1.75);
expect(viewerPixelRatio(NaN, false)).toBe(1.5);
});
it('fits a 2048px biased shadow to the loaded scene, including translated scenes', () => {
const light = new THREE.DirectionalLight();
fitSceneShadow(light, [12, -3, 2], 1.5);
expect(light.shadow.mapSize.toArray()).toEqual([2048, 2048]);
expect(light.target.position.toArray()).toEqual([12, -3, 2]);
expect(light.position.distanceTo(light.target.position)).toBeCloseTo(3);
expect(light.shadow.camera.left).toBe(-1.5);
expect(light.shadow.camera.right).toBe(1.5);
expect(light.shadow.camera.far).toBe(6);
expect(light.shadow.bias).toBeLessThan(0);
expect(light.shadow.normalBias).toBeGreaterThan(0);
fitSceneShadow(light, [0, 0, 0], 0.2);
expect(light.shadow.camera.right).toBe(1);
light.dispose();
});
it('filters magnified and oblique textures without changing their pixels', () => {
const pixels = new Uint8Array([255, 127, 0, 255]);
const texture = new THREE.DataTexture(pixels, 1, 1);
filterSurfaceTexture(texture, 16);
expect(texture.magFilter).toBe(THREE.LinearFilter);
expect(texture.minFilter).toBe(THREE.LinearMipmapLinearFilter);
expect(texture.generateMipmaps).toBe(true);
expect(texture.anisotropy).toBe(8);
expect(texture.image.data).toBe(pixels);
filterSurfaceTexture(texture, 2);
expect(texture.anisotropy).toBe(2);
texture.dispose();
});
it('resizes logical CSS dimensions independently of DPR, including DPR-only changes', () => {
vi.stubGlobal('devicePixelRatio', 2);
try {
let pixelRatio = 1;
const renderer = {
getPixelRatio: () => pixelRatio,
setPixelRatio: vi.fn((value: number) => {
pixelRatio = value;
}),
setSize: vi.fn(),
};
const viewer = Object.assign(Object.create(MuJoCoViewer.prototype), {
host: { clientWidth: 800, clientHeight: 600 },
renderer,
camera: new THREE.PerspectiveCamera(),
softwareRendering: false,
resizeWidth: 800,
resizeHeight: 600,
}) as { resize(): void };
viewer.resize();
expect(renderer.setPixelRatio).toHaveBeenCalledWith(2);
// Do not pass updateStyle=false: the DOM canvas would become 1600x1200 CSS pixels.
expect(renderer.setSize).toHaveBeenCalledWith(800, 600);
viewer.resize();
expect(renderer.setSize).toHaveBeenCalledTimes(1);
} finally {
vi.unstubAllGlobals();
}
});
+45
View File
@@ -0,0 +1,45 @@
import * as THREE from 'three';
/** Supersample low-DPI GPU displays too; software WebGL keeps its latency budget. */
export function viewerPixelRatio(dpr: number, software: boolean): number {
const ratio = Number.isFinite(dpr) && dpr > 0 ? dpr : 1;
return software ? Math.min(ratio, 1.75) : Math.min(Math.max(ratio, 1.5), 2);
}
/** Fit shadow texels to the loaded scene instead of a default 10 m / 512 px map. */
export function fitSceneShadow(
light: THREE.DirectionalLight,
center: readonly number[],
extent: number,
): void {
const radius = Math.max(1, extent);
light.target.position.set(center[0], center[1], center[2]);
light.position
.set(4, -3, 7)
.normalize()
.multiplyScalar(radius * 2)
.add(light.target.position);
const shadow = light.shadow;
shadow.mapSize.set(2048, 2048);
Object.assign(shadow.camera, {
left: -radius,
right: radius,
top: radius,
bottom: -radius,
near: radius * 0.01,
far: radius * 4,
});
shadow.bias = -0.00005;
shadow.normalBias = radius / 4096;
shadow.camera.updateProjectionMatrix();
shadow.needsUpdate = true;
}
/** DataTexture defaults to nearest-neighbour; oblique camera views need mipmaps. */
export function filterSurfaceTexture(texture: THREE.DataTexture, maxAnisotropy: number): void {
texture.magFilter = THREE.LinearFilter;
texture.minFilter = THREE.LinearMipmapLinearFilter;
texture.generateMipmaps = true;
texture.anisotropy = Math.min(8, maxAnisotropy);
texture.needsUpdate = true;
}