feat(3d): refine scene controls and camera views
Keep the control panel geometry stable when toggling building context, and use accessible switch controls to prevent focus-driven movement. Add browser coverage for camera framing, opacity, responsive layout, and the default 6x pipe scale.
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { mockBackend } from "./support/mockBackend";
|
||||
|
||||
test.setTimeout(180_000);
|
||||
|
||||
type CameraProbe = {
|
||||
active: string | null;
|
||||
position: number[];
|
||||
target: number[];
|
||||
up: number[];
|
||||
};
|
||||
|
||||
type SceneProbe = {
|
||||
errors: string[];
|
||||
contextVisible?: boolean;
|
||||
camera?: CameraProbe;
|
||||
appearance?: { contextOpacity: number };
|
||||
networkStyle?: { style: { scale: number } };
|
||||
};
|
||||
|
||||
test("三维页面默认展开场景工具并可调建筑透明度", async ({ page }) => {
|
||||
await mockBackend(page);
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("active_project", "playwright-project");
|
||||
localStorage.setItem("MAP_WORKSPACE", "zjb");
|
||||
localStorage.setItem("NETWORK_NAME", "zjb");
|
||||
});
|
||||
await page.goto("/three-dimensional-scene");
|
||||
|
||||
await expect(page.getByLabel("三维场景控制面板")).toBeVisible();
|
||||
await expect(page.getByRole("tab", { name: "场景" })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
const opacitySlider = page.getByRole("slider", {
|
||||
name: /建筑背景透明度/,
|
||||
});
|
||||
await expect(opacitySlider).toBeEnabled({ timeout: 60_000 });
|
||||
await expect(opacitySlider).toHaveAccessibleName("建筑背景透明度 60%");
|
||||
|
||||
const backgroundToggle = page.getByRole("switch", { name: "建筑背景" });
|
||||
await backgroundToggle.evaluate((element) =>
|
||||
element.scrollIntoView({ block: "center" }),
|
||||
);
|
||||
const panel = page.getByLabel("三维场景控制面板");
|
||||
const panelBeforeToggle = await panel.boundingBox();
|
||||
const panelScrollBeforeToggle = await panel.locator(".overflow-y-auto").evaluate(
|
||||
(element) => element.scrollTop,
|
||||
);
|
||||
await backgroundToggle.click();
|
||||
await expect(backgroundToggle).toHaveAttribute("aria-checked", "false");
|
||||
const panelAfterToggle = await panel.boundingBox();
|
||||
const panelScrollAfterToggle = await panel.locator(".overflow-y-auto").evaluate(
|
||||
(element) => element.scrollTop,
|
||||
);
|
||||
expect(panelAfterToggle).toEqual(panelBeforeToggle);
|
||||
expect(panelScrollAfterToggle).toBe(panelScrollBeforeToggle);
|
||||
|
||||
await page.setViewportSize({ width: 375, height: 812 });
|
||||
const mobilePanelBeforeToggle = await panel.boundingBox();
|
||||
await backgroundToggle.click();
|
||||
await expect(backgroundToggle).toHaveAttribute("aria-checked", "true");
|
||||
const mobilePanelAfterToggle = await panel.boundingBox();
|
||||
expect(mobilePanelAfterToggle).toEqual(mobilePanelBeforeToggle);
|
||||
await expect(opacitySlider).toBeEnabled();
|
||||
expect(
|
||||
await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("全部预设使用稳定且紧凑的场景视角", async ({ page }) => {
|
||||
await page.goto(
|
||||
"/three-dimensional/zjb/v29/preview.html?host=platform2&v=32-context-opacity-test",
|
||||
);
|
||||
await expect(page.locator("#boot")).toBeHidden({ timeout: 60_000 });
|
||||
|
||||
const sceneProbe = () =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
(window as typeof window & { webQA: SceneProbe }).webQA,
|
||||
);
|
||||
|
||||
await expect.poll(async () => (await sceneProbe()).camera?.active).toBe("overview");
|
||||
const initialProbe = await sceneProbe();
|
||||
const overview = initialProbe.camera;
|
||||
expect(overview?.up[1]).toBeGreaterThan(0.8);
|
||||
expect(initialProbe.appearance?.contextOpacity).toBe(0.4);
|
||||
expect(initialProbe.networkStyle?.style.scale).toBe(6);
|
||||
|
||||
await page.evaluate(() =>
|
||||
window.postMessage(
|
||||
{
|
||||
channel: "tjwater:zjb-scene",
|
||||
version: 2,
|
||||
type: "command",
|
||||
projectCode: "zjb",
|
||||
modelId: "zjb-water-network-v23",
|
||||
command: { name: "set-appearance", patch: { contextOpacity: 0.35 } },
|
||||
},
|
||||
window.location.origin,
|
||||
),
|
||||
);
|
||||
await expect
|
||||
.poll(async () => (await sceneProbe()).appearance?.contextOpacity)
|
||||
.toBe(0.35);
|
||||
|
||||
const cameraBeforeContextToggle = (await sceneProbe()).camera;
|
||||
await page.evaluate(() =>
|
||||
window.postMessage(
|
||||
{
|
||||
channel: "tjwater:zjb-scene",
|
||||
version: 2,
|
||||
type: "command",
|
||||
projectCode: "zjb",
|
||||
modelId: "zjb-water-network-v23",
|
||||
command: { name: "toggle-context" },
|
||||
},
|
||||
window.location.origin,
|
||||
),
|
||||
);
|
||||
await expect.poll(async () => (await sceneProbe()).contextVisible).toBe(false);
|
||||
const cameraAfterContextToggle = (await sceneProbe()).camera;
|
||||
expect(cameraAfterContextToggle?.position).toEqual(cameraBeforeContextToggle?.position);
|
||||
expect(cameraAfterContextToggle?.target).toEqual(cameraBeforeContextToggle?.target);
|
||||
await page.evaluate(() =>
|
||||
window.postMessage(
|
||||
{
|
||||
channel: "tjwater:zjb-scene",
|
||||
version: 2,
|
||||
type: "command",
|
||||
projectCode: "zjb",
|
||||
modelId: "zjb-water-network-v23",
|
||||
command: { name: "toggle-context" },
|
||||
},
|
||||
window.location.origin,
|
||||
),
|
||||
);
|
||||
|
||||
await page.evaluate(() =>
|
||||
window.postMessage(
|
||||
{
|
||||
channel: "tjwater:zjb-scene",
|
||||
version: 2,
|
||||
type: "command",
|
||||
projectCode: "zjb",
|
||||
modelId: "zjb-water-network-v23",
|
||||
command: { name: "visit-camera", viewId: "plan" },
|
||||
},
|
||||
window.location.origin,
|
||||
),
|
||||
);
|
||||
|
||||
await expect.poll(async () => (await sceneProbe()).camera?.active).toBe("plan");
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const camera = (await sceneProbe()).camera;
|
||||
return Math.max(
|
||||
Math.abs((camera?.position[0] ?? 0) - (camera?.target[0] ?? 1)),
|
||||
Math.abs((camera?.position[2] ?? 0) - (camera?.target[2] ?? 1)),
|
||||
);
|
||||
})
|
||||
.toBeLessThan(0.01);
|
||||
const planProbe = await sceneProbe();
|
||||
const plan = planProbe.camera;
|
||||
expect(planProbe.errors).toEqual([]);
|
||||
expect(Math.abs((plan?.position[0] ?? 0) - (plan?.target[0] ?? 1))).toBeLessThan(0.01);
|
||||
expect(Math.abs((plan?.position[2] ?? 0) - (plan?.target[2] ?? 1))).toBeLessThan(0.01);
|
||||
expect(plan?.up[0]).toBeCloseTo(0, 6);
|
||||
expect(plan?.up[1]).toBeCloseTo(0, 6);
|
||||
expect(plan?.up[2]).toBeCloseTo(-1, 6);
|
||||
|
||||
for (const viewId of [
|
||||
"pump",
|
||||
"hydraulic",
|
||||
"singlePump",
|
||||
"main",
|
||||
"meter",
|
||||
"crossing",
|
||||
"station",
|
||||
"riser",
|
||||
]) {
|
||||
await page.evaluate((nextViewId) =>
|
||||
window.postMessage(
|
||||
{
|
||||
channel: "tjwater:zjb-scene",
|
||||
version: 2,
|
||||
type: "command",
|
||||
projectCode: "zjb",
|
||||
modelId: "zjb-water-network-v23",
|
||||
command: { name: "visit-camera", viewId: nextViewId },
|
||||
},
|
||||
window.location.origin,
|
||||
),
|
||||
viewId,
|
||||
);
|
||||
await expect.poll(async () => (await sceneProbe()).camera?.active).toBe(viewId);
|
||||
const presetProbe = await sceneProbe();
|
||||
expect(presetProbe.errors).toEqual([]);
|
||||
expect(presetProbe.camera?.position.every(Number.isFinite)).toBe(true);
|
||||
expect(presetProbe.camera?.target.every(Number.isFinite)).toBe(true);
|
||||
expect(presetProbe.camera?.up[1]).toBeGreaterThan(0.99);
|
||||
}
|
||||
|
||||
const occupancy = await page.evaluate(async () => {
|
||||
const threeUrl =
|
||||
"/three-dimensional/zjb/v29/vendor/three/three.module.js";
|
||||
const navigationUrl =
|
||||
"/three-dimensional/zjb/v29/camera-navigation.mjs?v=31-camera-presets-test";
|
||||
const THREE = await import(threeUrl);
|
||||
const { framePose } = await import(navigationUrl);
|
||||
const aspect = 16 / 9;
|
||||
const box = new THREE.Box3(
|
||||
new THREE.Vector3(-330, 0, -205),
|
||||
new THREE.Vector3(330, 15, 205),
|
||||
);
|
||||
const pose = framePose(box, aspect, {
|
||||
direction: [0, 1, 0],
|
||||
up: [0, 0, -1],
|
||||
padding: 1.04,
|
||||
});
|
||||
const camera = new THREE.PerspectiveCamera(42, aspect, 0.1, 10_000);
|
||||
camera.position.fromArray(pose.position);
|
||||
camera.up.fromArray(pose.up);
|
||||
camera.lookAt(new THREE.Vector3(...pose.target));
|
||||
camera.updateProjectionMatrix();
|
||||
camera.updateMatrixWorld();
|
||||
const points = [
|
||||
[-330, 0, -205],
|
||||
[-330, 0, 205],
|
||||
[-330, 15, -205],
|
||||
[-330, 15, 205],
|
||||
[330, 0, -205],
|
||||
[330, 0, 205],
|
||||
[330, 15, -205],
|
||||
[330, 15, 205],
|
||||
].map((point) => new THREE.Vector3(...point).project(camera));
|
||||
return {
|
||||
x: Math.max(...points.map((point) => Math.abs(point.x))),
|
||||
y: Math.max(...points.map((point) => Math.abs(point.y))),
|
||||
};
|
||||
});
|
||||
expect(occupancy.x).toBeLessThanOrEqual(1);
|
||||
expect(occupancy.y).toBeLessThanOrEqual(1);
|
||||
expect(Math.max(occupancy.x, occupancy.y)).toBeGreaterThan(0.9);
|
||||
});
|
||||
Reference in New Issue
Block a user