feat(web): add dynamic area layouts and camera views

Area headers now expose a real ⋮ menu with split, join, swap,
duplicate, maximize/focus, close and reset; the first operation
measures the CSS grid into normalized rects and switches that
workspace to an OPFS-persisted custom layout while reusing the
existing React content per slot.

ViewportNavigation gains Numpad0 camera view with last-view restore,
framing-only navigation when unlocked, Camera to View lock that writes
the camera object (rotation-mode-aware native setObjectTransform),
and Ctrl+Alt+Numpad0 camera-to-view. Camera lock persists per area.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
mes123456
2026-11-13 09:50:12 +08:00
parent ebf1e8e873
commit 371427a6bc
18 changed files with 801 additions and 40 deletions

View File

@@ -0,0 +1,86 @@
import { expect, test } from "@playwright/test";
import path from "node:path";
import { fileURLToPath } from "node:url";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const STUDIO = path.resolve(HERE, "../../app/public/tutorial/pbr/studio.blend");
test.use({ viewport: { width: 1600, height: 900 } });
async function openStudio(page: import("@playwright/test").Page): Promise<void> {
await page.goto("/?ui=web");
await page.locator("main.blender-app").waitFor({ timeout: 120_000 });
await page.locator('[data-testid="blend-file-input"]').setInputFiles(STUDIO);
await expect.poll(async () => page.getByTestId("scene-stats").innerText(), { timeout: 120_000 }).toMatch(/Objects 6/);
await page.waitForTimeout(800);
}
async function openAreaMenu(page: import("@playwright/test").Page, area: import("@playwright/test").Locator): Promise<void> {
await area.locator(".area-menu button").evaluate((element) => (element as HTMLElement).click());
await page.getByRole("menu").last().waitFor({ timeout: 10_000 });
}
test("Area menu splits, joins, swaps, maximizes and resets the web layout", async ({ page }) => {
test.setTimeout(300_000);
await openStudio(page);
const app = page.locator("main.blender-app");
const grid = page.locator(".workspace-grid");
const properties = page.locator(".properties-area");
await expect.poll(async () => grid.locator(":scope > .area-frame").count()).toBe(4);
await openAreaMenu(page, properties);
await page.getByRole("menuitem", { name: "垂直分割(左右)", exact: true }).click();
await expect(app).toHaveAttribute("data-area-layout", "custom");
await expect.poll(async () => grid.locator(":scope > .area-frame").count()).toBe(5);
const split = page.locator('.area-frame[data-web-area-id="area-1"]');
await expect(split).toBeVisible();
await openAreaMenu(page, split);
await page.getByRole("menuitem", { name: "最大化区域", exact: true }).click();
await expect.poll(async () => grid.locator(":scope > .area-frame").count()).toBe(1);
await openAreaMenu(page, split);
await page.getByRole("menuitem", { name: "还原大小", exact: true }).click();
await expect.poll(async () => grid.locator(":scope > .area-frame").count()).toBe(5);
await openAreaMenu(page, split);
await page.getByRole("menuitem", { name: "交换编辑器", exact: true }).click();
const swapItem = page.getByRole("menuitem").filter({ hasText: "viewport-area" }).first();
await expect(swapItem).toBeVisible({ timeout: 10_000 });
await swapItem.click();
await expect.poll(async () => page.locator('.area-frame[data-web-area-id="area-1"]').getAttribute("data-editor")).not.toBe("Properties");
await openAreaMenu(page, split);
await page.getByRole("menuitem", { name: "复制区域", exact: true }).click();
await expect.poll(async () => grid.locator(":scope > .area-frame").count()).toBe(6);
const duplicated = page.locator('.area-frame[data-web-area-id="area-2"]');
await expect(duplicated).toBeVisible();
await openAreaMenu(page, duplicated);
await page.getByRole("menuitem", { name: "合并 / 停靠", exact: true }).click();
let joined = false;
for (const label of ["合并到左侧", "合并到右侧", "合并到上方", "合并到下方"]) {
const item = page.getByRole("menuitem", { name: label, exact: true });
if (await item.count() && await item.isEnabled()) { await item.click(); joined = true; break; }
}
expect(joined).toBe(true);
await expect.poll(async () => grid.locator(":scope > .area-frame").count()).toBe(5);
await openAreaMenu(page, page.locator(".workspace-grid > .area-frame").first());
await page.getByRole("menuitem", { name: "重置工作区布局", exact: true }).click();
await expect(app).toHaveAttribute("data-area-layout", "default");
await expect.poll(async () => grid.locator(":scope > .area-frame").count()).toBe(4);
});
test("Custom area layout survives an OPFS reload", async ({ page }) => {
test.setTimeout(300_000);
await openStudio(page);
const properties = page.locator(".properties-area");
await openAreaMenu(page, properties);
await page.getByRole("menuitem", { name: "水平分割(上下)", exact: true }).click();
await expect.poll(async () => page.locator(".workspace-grid > .area-frame").count()).toBe(5);
await page.waitForTimeout(900);
await page.reload();
await page.locator("main.blender-app").waitFor({ timeout: 120_000 });
await expect.poll(async () => page.locator("main.blender-app").getAttribute("data-area-layout"), { timeout: 30_000 }).toBe("custom");
await expect.poll(async () => page.locator(".workspace-grid > .area-frame").count(), { timeout: 30_000 }).toBe(5);
await expect(page.locator('.area-frame[data-web-area-id="area-1"]')).toBeVisible();
});

View File

@@ -0,0 +1,81 @@
import { expect, test } from "@playwright/test";
import path from "node:path";
import { fileURLToPath } from "node:url";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const STUDIO = path.resolve(HERE, "../../app/public/tutorial/pbr/studio.blend");
test.use({ viewport: { width: 1600, height: 900 } });
async function openStudio(page: import("@playwright/test").Page): Promise<import("@playwright/test").Locator> {
await page.goto("/?ui=web");
await page.locator("main.blender-app").waitFor({ timeout: 120_000 });
await page.locator('[data-testid="blend-file-input"]').setInputFiles(STUDIO);
await expect.poll(async () => page.getByTestId("scene-stats").innerText(), { timeout: 120_000 }).toMatch(/Objects 6/);
await page.waitForTimeout(1500);
const canvas = page.locator(".viewport-area canvas.viewport-canvas").first();
await canvas.waitFor({ timeout: 60_000 });
await canvas.focus();
await page.waitForTimeout(300);
return canvas;
}
async function dragMiddle(page: import("@playwright/test").Page, canvas: import("@playwright/test").Locator): Promise<void> {
const box = await canvas.boundingBox();
if (!box) throw new Error("viewport has no box");
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down({ button: "middle" });
await page.mouse.move(box.x + box.width / 2 + 50, box.y + box.height / 2 + 25, { steps: 10 });
await page.mouse.up({ button: "middle" });
await page.waitForTimeout(700);
}
test("Camera view aligns to the scene camera and restores the last view", async ({ page }) => {
test.setTimeout(300_000);
const canvas = await openStudio(page);
await expect(page.locator("main.blender-app")).toHaveAttribute("data-project-snapshot-revision", /^[0-9]+$/);
const before = {
position: await canvas.getAttribute("data-camera-position"),
yaw: await canvas.getAttribute("data-camera-yaw"),
pitch: await canvas.getAttribute("data-camera-pitch"),
revision: await page.locator("main.blender-app").getAttribute("data-project-snapshot-revision"),
};
await page.keyboard.press("Numpad0");
await expect(canvas).toHaveAttribute("data-camera-view", "CAMERA", { timeout: 30_000 });
await expect.poll(async () => canvas.getAttribute("data-camera-position"), { timeout: 30_000 }).toBe("7.2,-7.2,5.4");
await expect(page.locator("main.blender-app")).toHaveAttribute("data-project-snapshot-revision", before.revision ?? "0");
await dragMiddle(page, canvas);
await expect(canvas).toHaveAttribute("data-camera-view", "CAMERA");
await expect.poll(async () => canvas.getAttribute("data-camera-position")).toBe("7.2,-7.2,5.4");
await expect(page.locator("main.blender-app")).toHaveAttribute("data-project-snapshot-revision", before.revision ?? "0");
const zoomBefore = await canvas.getAttribute("data-camera-zoom");
await canvas.evaluate((element) => element.dispatchEvent(new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: -120 })));
await expect.poll(async () => canvas.getAttribute("data-camera-zoom"), { timeout: 10_000 }).not.toBe(zoomBefore);
await expect(page.locator("main.blender-app")).toHaveAttribute("data-project-snapshot-revision", before.revision ?? "0");
await page.keyboard.press("Numpad0");
await expect(canvas).toHaveAttribute("data-camera-view", "USER", { timeout: 30_000 });
await expect.poll(async () => canvas.getAttribute("data-camera-position"), { timeout: 30_000 }).toBe(before.position);
await expect(canvas).toHaveAttribute("data-camera-yaw", before.yaw ?? "");
await expect(canvas).toHaveAttribute("data-camera-pitch", before.pitch ?? "");
});
test("Camera to View writes the camera and Camera to View lock moves it", async ({ page }) => {
test.setTimeout(300_000);
const canvas = await openStudio(page);
const before = Number(await page.locator("main.blender-app").getAttribute("data-project-snapshot-revision") ?? "0");
await page.keyboard.press("Control+Alt+Numpad0");
await expect.poll(async () => Number(await page.locator("main.blender-app").getAttribute("data-project-snapshot-revision") ?? "0"), { timeout: 60_000 }).toBeGreaterThan(before);
await expect(canvas).toHaveAttribute("data-camera-view", "CAMERA", { timeout: 30_000 });
await page.getByRole("button", { name: "锁定相机", exact: true }).evaluate((element) => (element as HTMLElement).click());
await expect(canvas).toHaveAttribute("data-camera-locked", "true");
const lockedRevision = Number(await page.locator("main.blender-app").getAttribute("data-project-snapshot-revision") ?? "0");
await dragMiddle(page, canvas);
await expect.poll(async () => Number(await page.locator("main.blender-app").getAttribute("data-project-snapshot-revision") ?? "0"), { timeout: 60_000 }).toBeGreaterThan(lockedRevision);
await expect(canvas).toHaveAttribute("data-camera-view", "CAMERA");
const otherCanvas = page.locator(".camera-view-area canvas.viewport-canvas").first();
if (await otherCanvas.count()) await expect(otherCanvas).not.toHaveAttribute("data-camera-locked", "true");
});