auto-git:

[add] playwright.config.js
 [add] src/app/App.js
 [add] src/app/editor-store.js
 [add] src/app/use-editor-store.js
 [add] src/assets/audio-assets.js
 [add] src/assets/gltf-model-import.js
 [add] src/assets/image-assets.js
 [add] src/assets/model-instance-labels.js
 [add] src/assets/model-instance-rendering.js
 [add] src/assets/model-instances.js
 [add] src/assets/project-asset-storage.js
 [add] src/assets/project-assets.js
 [add] src/commands/brush-command-helpers.js
 [add] src/commands/command-history.js
 [add] src/commands/command.js
 [add] src/commands/commit-transform-session-command.js
 [add] src/commands/create-box-brush-command.js
 [add] src/commands/delete-box-brush-command.js
 [add] src/commands/delete-entity-command.js
 [add] src/commands/delete-interaction-link-command.js
 [add] src/commands/delete-model-instance-command.js
 [add] src/commands/import-audio-asset-command.js
 [add] src/commands/import-background-image-asset-command.js
 [add] src/commands/import-model-asset-command.js
 [add] src/commands/move-box-brush-command.js
 [add] src/commands/resize-box-brush-command.js
 [add] src/commands/rotate-box-brush-command.js
 [add] src/commands/set-box-brush-face-material-command.js
 [add] src/commands/set-box-brush-face-uv-state-command.js
 [add] src/commands/set-box-brush-name-command.js
 [add] src/commands/set-box-brush-transform-command.js
 [add] src/commands/set-entity-name-command.js
 [add] src/commands/set-model-instance-name-command.js
 [add] src/commands/set-player-start-command.js
 [add] src/commands/set-scene-name-command.js
 [add] src/commands/set-world-settings-command.js
 [add] src/commands/upsert-entity-command.js
 [add] src/commands/upsert-interaction-link-command.js
 [add] src/commands/upsert-model-instance-command.js
 [add] src/core/ids.js
 [add] src/core/selection.js
 [add] src/core/tool-mode.js
 [add] src/core/transform-session.js
 [add] src/core/vector.js
 [add] src/core/whitebox-selection-feedback.js
 [add] src/core/whitebox-selection-mode.js
 [add] src/document/brushes.js
 [add] src/document/migrate-scene-document.js
 [add] src/document/scene-document-validation.js
 [add] src/document/scene-document.js
 [add] src/document/world-settings.js
 [add] src/entities/entity-instances.js
 [add] src/entities/entity-labels.js
 [add] src/geometry/box-brush-components.js
 [add] src/geometry/box-brush-mesh.js
 [add] src/geometry/box-brush.js
 [add] src/geometry/box-face-uvs.js
 [add] src/geometry/grid-snapping.js
 [add] src/geometry/model-instance-collider-debug-mesh.js
 [add] src/geometry/model-instance-collider-generation.js
 [add] src/interactions/interaction-links.js
 [add] src/main.js
 [add] src/materials/starter-material-library.js
 [add] src/materials/starter-material-textures.js
 [add] src/rendering/advanced-rendering.js
 [add] src/runner-web/RunnerCanvas.js
 [add] src/runtime-three/first-person-navigation-controller.js
 [add] src/runtime-three/navigation-controller.js
 [add] src/runtime-three/orbit-visitor-navigation-controller.js
 [add] src/runtime-three/player-collision.js
 [add] src/runtime-three/rapier-collision-world.js
 [add] src/runtime-three/runtime-audio-system.js
 [add] src/runtime-three/runtime-host.js
 [add] src/runtime-three/runtime-interaction-system.js
 [add] src/runtime-three/runtime-scene-build.js
 [add] src/runtime-three/runtime-scene-validation.js
 [add] src/serialization/local-draft-storage.js
 [add] src/serialization/scene-document-json.js
 [add] src/shared-ui/HierarchicalMenu.js
 [add] src/shared-ui/Panel.js
 [add] src/shared-ui/world-background-style.js
 [add] src/viewport-three/ViewportCanvas.js
 [add] src/viewport-three/ViewportPanel.js
 [add] src/viewport-three/viewport-entity-markers.js
 [add] src/viewport-three/viewport-focus.js
 [add] src/viewport-three/viewport-host.js
 [add] src/viewport-three/viewport-layout.js
 [add] src/viewport-three/viewport-transient-state.js
 [add] src/viewport-three/viewport-view-modes.js
 [add] tests/domain/box-brush-face-editing.command.test.js
 [add] tests/domain/build-runtime-scene.test.js
 [add] tests/domain/create-box-brush.command.test.js
 [add] tests/domain/create-empty-scene-document.test.js
 [add] tests/domain/editor-store.test.js
 [add] tests/domain/entity.command.test.js
 [add] tests/domain/interaction-links.validation.test.js
 [add] tests/domain/model-import.test.js
 [add] tests/domain/model-instance.command.test.js
 [add] tests/domain/player-start.command.test.js
 [add] tests/domain/rapier-collision-world.test.js
 [add] tests/domain/runtime-audio-system.test.js
 [add] tests/domain/runtime-interaction-system.test.js
 [add] tests/domain/runtime-scene-validation.test.js
 [add] tests/domain/scene-document-validation.test.js
 [add] tests/domain/transform-session.command.test.js
 [add] tests/domain/world-settings.command.test.js
 [add] tests/domain/world-settings.test.js
 [add] tests/e2e/app-smoke.e2e.js
 [add] tests/e2e/box-brush-authoring.e2e.js
 [add] tests/e2e/entities-foundation.e2e.js
 [add] tests/e2e/face-material-authoring.e2e.js
 [add] tests/e2e/first-room-workflow.e2e.js
 [add] tests/e2e/import-draco-model-asset.e2e.js
 [add] tests/e2e/import-external-model-asset.e2e.js
 [add] tests/e2e/import-model-asset.e2e.js
 [add] tests/e2e/local-lights-and-background.e2e.js
 [add] tests/e2e/orthographic-views.e2e.js
 [add] tests/e2e/runner-v1.e2e.js
 [add] tests/e2e/runtime-click-interaction.e2e.js
 [add] tests/e2e/runtime-trigger-teleport.e2e.js
 [add] tests/e2e/viewport-quad-layout.e2e.js
 [add] tests/e2e/viewport-test-helpers.js
 [add] tests/e2e/whitebox-component-selection.e2e.js
 [add] tests/e2e/world-environment.e2e.js
 [add] tests/geometry/box-brush-geometry.test.js
 [add] tests/geometry/box-face-uvs.test.js
 [add] tests/geometry/model-instance-collider-generation.test.js
 [add] tests/helpers/model-collider-fixtures.js
 [add] tests/serialization/local-draft-storage.test.js
 [add] tests/serialization/project-asset-storage.test.js
 [add] tests/serialization/scene-document-json.test.js
 [add] tests/setup/vitest.setup.js
 [add] tests/unit/audio-assets.test.js
 [add] tests/unit/entity-instances.test.js
 [add] tests/unit/package-scripts.test.js
 [add] tests/unit/transform-foundation.integration.test.js
 [add] tests/unit/viewport-canvas.test.js
 [add] tests/unit/viewport-entity-markers.test.js
 [add] tests/unit/viewport-focus.test.js
 [add] tests/unit/viewport-layout.test.js
 [add] tests/unit/viewport-view-modes.test.js
 [add] vite.config.js
 [add] vitest.config.js
This commit is contained in:
2026-04-05 02:25:08 +02:00
parent b23cb0dd32
commit 0b54fa14c9
143 changed files with 26849 additions and 0 deletions

View File

@@ -0,0 +1,24 @@
import { expect, test } from "@playwright/test";
test("app boots and shows the viewport shell", async ({ page }) => {
const pageErrors = [];
const consoleErrors = [];
page.on("pageerror", (error) => {
pageErrors.push(error.message);
});
page.on("console", (message) => {
if (message.type() === "error") {
consoleErrors.push(message.text());
}
});
await page.goto("/");
await expect(page.getByTestId("toolbar-scene-name")).toHaveValue("Untitled Scene");
await expect(page.getByTestId("viewport-shell")).toBeVisible();
await expect(page.getByTestId("viewport-panel-topLeft")).toBeVisible();
await expect(page.getByTestId("viewport-layout-single")).toBeVisible();
await expect(page.getByTestId("viewport-layout-quad")).toBeVisible();
await expect(page.getByRole("button", { name: "World" })).toBeVisible();
await expect(page.getByTestId("world-background-mode-value")).toBeVisible();
await expect(page.getByTestId("enter-run-mode")).toBeVisible();
expect(pageErrors).toEqual([]);
expect(consoleErrors).toEqual([]);
});

View File

@@ -0,0 +1,103 @@
import { expect, test } from "@playwright/test";
import { beginBoxCreation, clickViewport, getEditorStoreSnapshot } from "./viewport-test-helpers";
test("user can create a whitebox box with float transforms and keep it through reload and run mode", async ({ page }) => {
const pageErrors = [];
const consoleErrors = [];
page.on("pageerror", (error) => {
pageErrors.push(error.message);
});
page.on("console", (message) => {
if (message.type() === "error") {
consoleErrors.push(message.text());
}
});
await page.goto("/");
await page.evaluate((storageKey) => {
window.localStorage.removeItem(storageKey);
}, "webeditor3d.scene-document-draft");
await page.reload();
await beginBoxCreation(page);
const creationSnapshot = await getEditorStoreSnapshot(page);
expect(creationSnapshot).toMatchObject({
toolMode: "create",
viewportTransientState: {
toolPreview: {
kind: "create",
sourcePanelId: "topLeft",
target: {
kind: "box-brush"
},
center: null
}
}
});
await page.keyboard.press("Escape");
const cancelledSnapshot = await getEditorStoreSnapshot(page);
expect(cancelledSnapshot).toMatchObject({
toolMode: "select",
viewportTransientState: {
toolPreview: {
kind: "none"
}
}
});
await beginBoxCreation(page);
await clickViewport(page);
await page.getByTestId("whitebox-snap-toggle").click();
const committedSnapshot = await getEditorStoreSnapshot(page);
expect(committedSnapshot).toMatchObject({
toolMode: "select",
viewportTransientState: {
toolPreview: {
kind: "none"
}
}
});
await expect(page.getByRole("button", { name: /Whitebox Box 1/ })).toBeVisible();
await expect(page.getByText("1 solid selected (Whitebox Box 1)")).toBeVisible();
await expect(page.getByTestId("apply-brush-position")).toHaveCount(0);
await expect(page.getByTestId("apply-brush-size")).toHaveCount(0);
await page.getByTestId("brush-center-x").fill("1.25");
await page.getByTestId("brush-center-x").press("Tab");
await page.getByTestId("brush-center-y").fill("2.125");
await page.getByTestId("brush-center-y").press("Tab");
await page.getByTestId("brush-rotation-y").fill("37.5");
await page.getByTestId("brush-rotation-y").press("Tab");
await page.getByTestId("brush-size-z").fill("4.5");
await page.getByTestId("brush-size-z").press("Tab");
await page.getByTestId("selected-brush-name").fill("Entry Room");
await page.getByTestId("selected-brush-name").press("Tab");
await expect(page.getByTestId("selected-brush-name")).toHaveValue("Entry Room");
await page.getByRole("button", { name: "Save Draft" }).click();
await page.reload();
await expect(page.getByRole("button", { name: /^Entry Room$/ })).toBeVisible();
await page.getByRole("button", { name: /^Entry Room$/ }).click();
await expect(page.getByTestId("brush-center-x")).toHaveValue("1.25");
await expect(page.getByTestId("brush-center-y")).toHaveValue("2.125");
await expect(page.getByTestId("brush-rotation-y")).toHaveValue("37.5");
await expect(page.getByTestId("brush-size-z")).toHaveValue("4.5");
await expect(page.getByTestId("viewport-overlay-topLeft")).toHaveCount(0);
await page.getByTestId("enter-run-mode").click();
await expect(page.getByTestId("runner-shell")).toBeVisible();
expect(pageErrors).toEqual([]);
expect(consoleErrors).toEqual([]);
});
test("switching selection while a transform input is active does not overwrite the newly selected brush", async ({ page }) => {
await page.goto("/");
await page.evaluate((storageKey) => {
window.localStorage.removeItem(storageKey);
}, "webeditor3d.scene-document-draft");
await page.reload();
await beginBoxCreation(page);
await clickViewport(page);
await beginBoxCreation(page);
await clickViewport(page);
const outlinerButtons = page.getByTestId("outliner-brush-list").getByRole("button");
await outlinerButtons.nth(0).click();
await page.getByTestId("brush-size-z").fill("4");
await outlinerButtons.nth(1).click();
await expect(page.getByText("1 solid selected (Whitebox Box 2)")).toBeVisible();
await expect(page.getByTestId("brush-size-z")).toHaveValue("2");
await outlinerButtons.nth(0).click();
await expect(page.getByTestId("brush-size-z")).toHaveValue("4");
});

View File

@@ -0,0 +1,155 @@
import { expect, test } from "@playwright/test";
import { clickViewport, getEditorStoreSnapshot, setViewportCreationPreview } from "./viewport-test-helpers";
test("user can place and select typed entities from the entity foundation workflow", async ({ page }) => {
const pageErrors = [];
const consoleErrors = [];
page.on("pageerror", (error) => {
pageErrors.push(error.message);
});
page.on("console", (message) => {
if (message.type() === "error") {
consoleErrors.push(message.text());
}
});
await page.goto("/");
await page.evaluate((storageKey) => {
window.localStorage.removeItem(storageKey);
}, "webeditor3d.scene-document-draft");
await page.reload();
await page.getByTestId("outliner-add-button").click();
await page.getByTestId("add-menu-entities").click();
await page.getByTestId("add-menu-sound-emitter").click();
await setViewportCreationPreview(page, "topLeft", { kind: "entity", entityKind: "soundEmitter", audioAssetId: null }, { x: 4, y: 1, z: -6 });
await expect(page.getByTestId("viewport-snap-preview-topLeft")).toBeVisible();
await clickViewport(page, "topLeft");
const soundEmitterSnapshot = await getEditorStoreSnapshot(page);
expect(soundEmitterSnapshot).toMatchObject({
toolMode: "select",
viewportTransientState: {
toolPreview: {
kind: "none"
}
}
});
const selectedSoundEmitterId = soundEmitterSnapshot.selection.kind === "entities" ? soundEmitterSnapshot.selection.ids?.[0] ?? null : null;
expect(selectedSoundEmitterId).not.toBeNull();
const selectedSoundEmitter = soundEmitterSnapshot.document.entities[selectedSoundEmitterId];
if (selectedSoundEmitter === undefined) {
throw new Error("Placed sound emitter is missing from the document snapshot.");
}
expect(selectedSoundEmitter.position).toMatchObject({
x: 4,
y: 1,
z: -6
});
await expect(page.getByTestId("sound-emitter-ref-distance")).toHaveValue("6");
await expect(page.getByTestId("sound-emitter-max-distance")).toHaveValue("24");
await page.getByTestId("sound-emitter-ref-distance").fill("9");
await page.getByTestId("sound-emitter-ref-distance").press("Tab");
await page.getByTestId("sound-emitter-autoplay").click();
await page.getByTestId("sound-emitter-loop").click();
await expect(page.getByTestId("sound-emitter-autoplay")).toBeChecked();
await expect(page.getByTestId("sound-emitter-loop")).toBeChecked();
await page.getByTestId("outliner-add-button").click();
await page.getByTestId("add-menu-entities").click();
await page.getByTestId("add-menu-interactable").click();
await setViewportCreationPreview(page, "topLeft", { kind: "entity", entityKind: "interactable", audioAssetId: null }, { x: -8, y: 1, z: 12 });
await expect(page.getByTestId("viewport-snap-preview-topLeft")).toBeVisible();
await clickViewport(page, "topLeft");
const interactableSnapshot = await getEditorStoreSnapshot(page);
expect(interactableSnapshot).toMatchObject({
toolMode: "select",
viewportTransientState: {
toolPreview: {
kind: "none"
}
}
});
await expect(page.getByTestId("interactable-prompt")).toHaveValue("Use");
await page
.locator('[data-testid^="outliner-entity-"]')
.filter({ hasText: "Sound Emitter" })
.first()
.click();
await expect(page.getByTestId("sound-emitter-ref-distance")).toHaveValue("9");
await expect(page.getByTestId("sound-emitter-autoplay")).toBeChecked();
await expect(page.getByTestId("sound-emitter-loop")).toBeChecked();
await expect(page.getByTestId("interactable-prompt")).toHaveCount(0);
expect(pageErrors).toEqual([]);
expect(consoleErrors).toEqual([]);
});
test("shift+a opens the add menu at the cursor", async ({ page }) => {
const pageErrors = [];
const consoleErrors = [];
page.on("pageerror", (error) => {
pageErrors.push(error.message);
});
page.on("console", (message) => {
if (message.type() === "error") {
consoleErrors.push(message.text());
}
});
await page.goto("/");
await page.evaluate((storageKey) => {
window.localStorage.removeItem(storageKey);
}, "webeditor3d.scene-document-draft");
await page.reload();
await page.mouse.move(420, 260);
await page.keyboard.press("Shift+A");
await expect(page.getByRole("menu", { name: "Add" })).toBeVisible();
await page.getByTestId("add-menu-lights").click();
await page.getByTestId("add-menu-point-light").click();
await setViewportCreationPreview(page, "topLeft", { kind: "entity", entityKind: "pointLight", audioAssetId: null }, { x: 12, y: 3, z: -4 });
await clickViewport(page, "topLeft");
await expect(page.getByTestId("point-light-intensity")).toHaveValue("1.25");
expect(pageErrors).toEqual([]);
expect(consoleErrors).toEqual([]);
});
test("escape cancels a typed entity creation preview", async ({ page }) => {
const pageErrors = [];
const consoleErrors = [];
page.on("pageerror", (error) => {
pageErrors.push(error.message);
});
page.on("console", (message) => {
if (message.type() === "error") {
consoleErrors.push(message.text());
}
});
await page.goto("/");
await page.evaluate((storageKey) => {
window.localStorage.removeItem(storageKey);
}, "webeditor3d.scene-document-draft");
await page.reload();
await page.getByTestId("outliner-add-button").click();
await page.getByTestId("add-menu-entities").click();
await page.getByTestId("add-menu-player-start").click();
const creationSnapshot = await getEditorStoreSnapshot(page);
expect(creationSnapshot).toMatchObject({
toolMode: "create",
viewportTransientState: {
toolPreview: {
kind: "create",
sourcePanelId: "topLeft",
target: {
kind: "entity",
entityKind: "playerStart",
audioAssetId: null
},
center: null
}
}
});
await page.keyboard.press("Escape");
const cancelledSnapshot = await getEditorStoreSnapshot(page);
expect(cancelledSnapshot).toMatchObject({
toolMode: "select",
viewportTransientState: {
toolPreview: {
kind: "none"
}
}
});
expect(pageErrors).toEqual([]);
expect(consoleErrors).toEqual([]);
});

View File

@@ -0,0 +1,31 @@
import { expect, test } from "@playwright/test";
import { beginBoxCreation, clickViewport } from "./viewport-test-helpers";
test("user can assign a face material through the UI and keep it through a draft reload", async ({ page }) => {
const pageErrors = [];
const consoleErrors = [];
page.on("pageerror", (error) => {
pageErrors.push(error.message);
});
page.on("console", (message) => {
if (message.type() === "error") {
consoleErrors.push(message.text());
}
});
await page.goto("/");
await page.evaluate((storageKey) => {
window.localStorage.removeItem(storageKey);
}, "webeditor3d.scene-document-draft");
await page.reload();
await beginBoxCreation(page);
await clickViewport(page);
await page.getByTestId("face-button-posZ").click();
await page.getByTestId("material-button-starter-amber-grid").click();
await expect(page.getByTestId("selected-face-material-name")).toContainText("Amber Grid");
await page.getByRole("button", { name: "Save Draft" }).click();
await page.reload();
await page.getByRole("button", { name: /Whitebox Box 1/ }).click();
await page.getByTestId("face-button-posZ").click();
await expect(page.getByTestId("selected-face-material-name")).toContainText("Amber Grid");
expect(pageErrors).toEqual([]);
expect(consoleErrors).toEqual([]);
});

View File

@@ -0,0 +1,54 @@
import { expect, test } from "@playwright/test";
import { beginBoxCreation, clickViewport, setViewportCreationPreview } from "./viewport-test-helpers";
test("first-room workflow covers create, texture, save/load, and run", async ({ page }) => {
const pageErrors = [];
const consoleErrors = [];
page.on("pageerror", (error) => {
pageErrors.push(error.message);
});
page.on("console", (message) => {
if (message.type() === "error") {
consoleErrors.push(message.text());
}
});
await page.goto("/");
await page.evaluate((storageKey) => {
window.localStorage.removeItem(storageKey);
}, "webeditor3d.scene-document-draft");
await page.reload();
await beginBoxCreation(page);
await clickViewport(page);
await page.getByTestId("face-button-posZ").click();
await page.getByTestId("material-button-starter-amber-grid").click();
await page.getByRole("button", { name: "First Person" }).click();
await expect(page.getByTestId("status-message")).toContainText("Author a Player Start before running");
await expect(page.getByTestId("status-run-preflight")).toContainText("Blocked");
await page.getByTestId("outliner-add-button").click();
await page.getByTestId("add-menu-entities").click();
await page.getByTestId("add-menu-player-start").click();
await setViewportCreationPreview(page, "topLeft", { kind: "entity", entityKind: "playerStart", audioAssetId: null }, { x: 0, y: 0, z: 0 });
await clickViewport(page, "topLeft");
await page.getByTestId("player-start-position-x").fill("2");
await page.getByTestId("player-start-position-x").press("Tab");
await page.getByTestId("player-start-position-z").fill("-2");
await page.getByTestId("player-start-position-z").press("Tab");
await page.getByTestId("player-start-yaw").fill("90");
await page.getByTestId("player-start-yaw").press("Tab");
await expect(page.getByTestId("status-run-preflight")).toContainText("Ready for First Person");
await page.getByRole("button", { name: "Save Draft" }).click();
await beginBoxCreation(page);
await clickViewport(page);
await expect(page.getByRole("button", { name: /Whitebox Box 2/ })).toBeVisible();
await page.getByRole("button", { name: "Load Draft" }).click();
await expect(page.getByRole("button", { name: /Whitebox Box 2/ })).toHaveCount(0);
await page.getByRole("button", { name: /Whitebox Box 1/ }).click();
await page.getByTestId("face-button-posZ").click();
await expect(page.getByTestId("selected-face-material-name")).toContainText("Amber Grid");
await page.getByTestId("enter-run-mode").click();
await expect(page.getByTestId("runner-shell")).toBeVisible();
await expect(page.getByTestId("runner-spawn-state")).toContainText("Player Start");
await expect(page.getByTestId("runner-player-position")).toContainText("2.00,");
await expect(page.getByTestId("runner-player-position")).toContainText(", -2.00");
expect(pageErrors).toEqual([]);
expect(consoleErrors).toEqual([]);
});

View File

@@ -0,0 +1,59 @@
import path from "node:path";
import { expect, test } from "@playwright/test";
import { clickViewport, getEditorStoreSnapshot, setViewportCreationPreview } from "./viewport-test-helpers";
const fixturePath = path.resolve(process.cwd(), "fixtures/assets/tiny-triangle-draco.glb");
test("imports a draco-compressed glb asset, places an instance, and survives reload", async ({ page }) => {
const pageErrors = [];
const consoleErrors = [];
page.on("pageerror", (error) => {
pageErrors.push(error.message);
});
page.on("console", (message) => {
if (message.type() === "error") {
consoleErrors.push(message.text());
}
});
await page.goto("/");
await page.getByTestId("outliner-add-button").click();
await page.getByTestId("add-menu-import").click();
await page.getByTestId("import-menu-model").click();
await page.locator('input[type="file"][accept*="gltf"]').setInputFiles(fixturePath);
await expect(page.getByTestId("outliner-model-instance-list").getByRole("button")).toHaveCount(1);
await page.getByTestId("outliner-add-button").click();
await page.getByTestId("add-menu-assets").click();
await page.getByTestId("add-menu-assets-models").click();
const addMenu = page.getByRole("menu", { name: "Add" });
await expect(addMenu.getByRole("menuitem", { name: "tiny-triangle-draco.glb" })).toBeVisible();
await addMenu.getByRole("menuitem", { name: "tiny-triangle-draco.glb" }).click();
const importedSnapshot = await getEditorStoreSnapshot(page);
const importedModelAsset = Object.values(importedSnapshot.document.assets).find((asset) => asset.kind === "model" && asset.sourceName === "tiny-triangle-draco.glb");
if (importedModelAsset === undefined) {
throw new Error("Imported model asset was not found in the document snapshot.");
}
await setViewportCreationPreview(page, "topLeft", { kind: "model-instance", assetId: importedModelAsset.id }, { x: 84, y: 0, z: -88 });
await expect(page.getByTestId("viewport-snap-preview-topLeft")).toBeVisible();
await clickViewport(page, "topLeft");
await expect(page.getByTestId("outliner-model-instance-list").getByRole("button")).toHaveCount(2);
const snapshot = await getEditorStoreSnapshot(page);
const selectedModelInstanceId = snapshot.selection.kind === "modelInstances" ? snapshot.selection.ids?.[0] ?? null : null;
expect(selectedModelInstanceId).not.toBeNull();
const selectedModelInstance = snapshot.document.modelInstances[selectedModelInstanceId];
if (selectedModelInstance === undefined) {
throw new Error("Placed model instance is missing from the document snapshot.");
}
expect(selectedModelInstance.position).toMatchObject({
x: 84,
z: -88
});
await page.getByRole("button", { name: "Save Draft" }).dispatchEvent("click");
await expect(page.getByTestId("status-message")).toContainText("Local draft saved.");
await page.reload();
await page.getByTestId("outliner-add-button").click();
await page.getByTestId("add-menu-assets").click();
await page.getByTestId("add-menu-assets-models").click();
await expect(page.getByRole("menu", { name: "Add" }).getByRole("menuitem", { name: "tiny-triangle-draco.glb" })).toBeVisible();
await expect(page.getByTestId("outliner-model-instance-list").getByRole("button")).toHaveCount(2);
await expect(page.getByTestId("asset-status-message")).toHaveCount(0);
expect(pageErrors).toEqual([]);
expect(consoleErrors).toEqual([]);
});

View File

@@ -0,0 +1,53 @@
import path from "node:path";
import { expect, test } from "@playwright/test";
import { clickViewport, getEditorStoreSnapshot, setViewportCreationPreview } from "./viewport-test-helpers";
const gltfFixturePath = path.resolve(process.cwd(), "fixtures/assets/external-triangle/scene.gltf");
const binFixturePath = path.resolve(process.cwd(), "fixtures/assets/external-triangle/triangle.bin");
test("imports a gltf asset with external resources and places an instance", async ({ page }) => {
const pageErrors = [];
const consoleErrors = [];
page.on("pageerror", (error) => {
pageErrors.push(error.message);
});
page.on("console", (message) => {
if (message.type() === "error") {
consoleErrors.push(message.text());
}
});
await page.goto("/");
await page.getByTestId("outliner-add-button").click();
await page.getByTestId("add-menu-import").click();
await page.getByTestId("import-menu-model").click();
await page.locator('input[type="file"][accept*="gltf"]').setInputFiles([gltfFixturePath, binFixturePath]);
await expect(page.getByTestId("outliner-model-instance-list").getByRole("button")).toHaveCount(1);
await page.getByTestId("outliner-add-button").click();
await page.getByTestId("add-menu-assets").click();
await page.getByTestId("add-menu-assets-models").click();
const addMenu = page.getByRole("menu", { name: "Add" });
await expect(addMenu.getByRole("menuitem", { name: "scene.gltf" })).toBeVisible();
await addMenu.getByRole("menuitem", { name: "scene.gltf" }).hover();
await expect(page.getByTestId("status-asset-hover")).toContainText("Storage key:");
await addMenu.getByRole("menuitem", { name: "scene.gltf" }).click();
const importedSnapshot = await getEditorStoreSnapshot(page);
const importedModelAsset = Object.values(importedSnapshot.document.assets).find((asset) => asset.kind === "model" && asset.sourceName === "scene.gltf");
if (importedModelAsset === undefined) {
throw new Error("Imported model asset was not found in the document snapshot.");
}
await setViewportCreationPreview(page, "topLeft", { kind: "model-instance", assetId: importedModelAsset.id }, { x: 88, y: 0, z: -84 });
await expect(page.getByTestId("viewport-snap-preview-topLeft")).toBeVisible();
await clickViewport(page, "topLeft");
await expect(page.getByTestId("outliner-model-instance-list").getByRole("button")).toHaveCount(2);
const snapshot = await getEditorStoreSnapshot(page);
const selectedModelInstanceId = snapshot.selection.kind === "modelInstances" ? snapshot.selection.ids?.[0] ?? null : null;
expect(selectedModelInstanceId).not.toBeNull();
const selectedModelInstance = snapshot.document.modelInstances[selectedModelInstanceId];
if (selectedModelInstance === undefined) {
throw new Error("Placed model instance is missing from the document snapshot.");
}
expect(selectedModelInstance.position).toMatchObject({
x: 88,
z: -84
});
expect(pageErrors).toEqual([]);
expect(consoleErrors).toEqual([]);
});

View File

@@ -0,0 +1,101 @@
import path from "node:path";
import { expect, test } from "@playwright/test";
import { clickViewport, getEditorStoreSnapshot, setViewportCreationPreview } from "./viewport-test-helpers";
const fixturePath = path.resolve(process.cwd(), "fixtures/assets/tiny-triangle.gltf");
test("imports a model asset, places an instance, and survives reload", async ({ page }) => {
const pageErrors = [];
const consoleErrors = [];
page.on("pageerror", (error) => {
pageErrors.push(error.message);
});
page.on("console", (message) => {
if (message.type() === "error") {
consoleErrors.push(message.text());
}
});
await page.goto("/");
await page.getByTestId("outliner-add-button").click();
await page.getByTestId("add-menu-import").click();
await expect(page.getByTestId("import-menu-model")).toBeVisible();
await expect(page.getByTestId("import-menu-environment")).toBeVisible();
await expect(page.getByTestId("import-menu-audio")).toBeVisible();
await page.getByTestId("import-menu-model").click();
await page.locator('input[type="file"][accept*="gltf"]').setInputFiles(fixturePath);
await expect(page.getByTestId("outliner-model-instance-list").getByRole("button")).toHaveCount(1);
await page.getByTestId("outliner-add-button").click();
await page.getByTestId("add-menu-assets").click();
await page.getByTestId("add-menu-assets-models").click();
const addMenu = page.getByRole("menu", { name: "Add" });
await expect(addMenu.getByRole("menuitem", { name: "tiny-triangle.gltf" })).toBeVisible();
await addMenu.getByRole("menuitem", { name: "tiny-triangle.gltf" }).hover();
await expect(page.getByTestId("status-asset-hover")).toContainText("Storage key:");
await addMenu.getByRole("menuitem", { name: "tiny-triangle.gltf" }).click();
const importedSnapshot = await getEditorStoreSnapshot(page);
expect(importedSnapshot).toMatchObject({
toolMode: "create",
viewportTransientState: {
toolPreview: {
kind: "create",
sourcePanelId: "topLeft",
target: {
kind: "model-instance",
assetId: expect.any(String)
},
center: null
}
}
});
const importedModelAsset = Object.values(importedSnapshot.document.assets).find((asset) => asset.kind === "model" && asset.sourceName === "tiny-triangle.gltf");
if (importedModelAsset === undefined) {
throw new Error("Imported model asset was not found in the document snapshot.");
}
await page.keyboard.press("Escape");
const cancelledSnapshot = await getEditorStoreSnapshot(page);
expect(cancelledSnapshot).toMatchObject({
toolMode: "select",
viewportTransientState: {
toolPreview: {
kind: "none"
}
}
});
await page.getByTestId("outliner-add-button").click();
await page.getByTestId("add-menu-assets").click();
await page.getByTestId("add-menu-assets-models").click();
await page.getByRole("menu", { name: "Add" }).getByRole("menuitem", { name: "tiny-triangle.gltf" }).click();
await setViewportCreationPreview(page, "topLeft", { kind: "model-instance", assetId: importedModelAsset.id }, { x: 92, y: 0, z: -76 });
await expect(page.getByTestId("viewport-snap-preview-topLeft")).toBeVisible();
await clickViewport(page, "topLeft");
const committedSnapshot = await getEditorStoreSnapshot(page);
expect(committedSnapshot).toMatchObject({
toolMode: "select",
viewportTransientState: {
toolPreview: {
kind: "none"
}
}
});
await expect(page.getByTestId("outliner-model-instance-list").getByRole("button")).toHaveCount(2);
const snapshot = committedSnapshot;
const selectedModelInstanceId = snapshot.selection.kind === "modelInstances" ? snapshot.selection.ids?.[0] ?? null : null;
expect(selectedModelInstanceId).not.toBeNull();
const selectedModelInstance = snapshot.document.modelInstances[selectedModelInstanceId];
if (selectedModelInstance === undefined) {
throw new Error("Placed model instance is missing from the document snapshot.");
}
expect(selectedModelInstance.position).toMatchObject({
x: 92,
z: -76
});
await page.getByRole("button", { name: "Save Draft" }).dispatchEvent("click");
await expect(page.getByTestId("status-message")).toContainText("Local draft saved.");
await page.reload();
await page.getByTestId("outliner-add-button").click();
await page.getByTestId("add-menu-assets").click();
await page.getByTestId("add-menu-assets-models").click();
await expect(page.getByRole("menu", { name: "Add" }).getByRole("menuitem", { name: "tiny-triangle.gltf" })).toBeVisible();
await expect(page.getByTestId("outliner-model-instance-list").getByRole("button")).toHaveCount(2);
await expect(page.getByTestId("asset-status-message")).toHaveCount(0);
expect(pageErrors).toEqual([]);
expect(consoleErrors).toEqual([]);
});

View File

@@ -0,0 +1,71 @@
import path from "node:path";
import { expect, test } from "@playwright/test";
import { clickViewport, setViewportCreationPreview } from "./viewport-test-helpers";
const panoramaFixturePath = path.resolve(process.cwd(), "fixtures/assets/skybox-panorama.svg");
test("local lights and background images persist through editor and runner flows", async ({ page }) => {
const pageErrors = [];
const consoleErrors = [];
page.on("pageerror", (error) => {
pageErrors.push(error.message);
});
page.on("console", (message) => {
if (message.type() === "error") {
consoleErrors.push(message.text());
}
});
await page.goto("/");
await page.evaluate((storageKey) => {
window.localStorage.removeItem(storageKey);
}, "webeditor3d.scene-document-draft");
await page.reload();
await page.getByTestId("outliner-add-button").click();
await page.getByTestId("add-menu-import").click();
await page.getByTestId("import-menu-environment").click();
await page.locator('input[type="file"][accept*="image"]').setInputFiles(panoramaFixturePath);
await page.getByTestId("outliner-add-button").click();
await page.getByTestId("add-menu-assets").click();
await page.getByTestId("add-menu-assets-environments").click();
const addMenu = page.getByRole("menu", { name: "Add" });
await expect(addMenu.getByRole("menuitem", { name: "skybox-panorama.svg" })).toBeVisible();
await addMenu.getByRole("menuitem", { name: "skybox-panorama.svg" }).click();
await expect(page.getByTestId("world-background-mode-value")).toContainText("Image");
await expect(page.getByTestId("world-background-asset-value")).toContainText("skybox-panorama.svg");
await expect(page.getByTestId("viewport-canvas-topLeft")).toHaveCSS("background-image", /url/);
await page.getByTestId("outliner-add-button").click();
await page.getByTestId("add-menu-lights").click();
await page.getByTestId("add-menu-point-light").click();
await setViewportCreationPreview(page, "topLeft", { kind: "entity", entityKind: "pointLight", audioAssetId: null }, { x: 12, y: 3, z: -4 });
await expect(page.getByTestId("viewport-snap-preview-topLeft")).toBeVisible();
await clickViewport(page, "topLeft");
await expect(page.getByTestId("point-light-distance")).toHaveValue("8");
await page.getByTestId("point-light-distance").fill("12");
await page.getByTestId("point-light-distance").press("Tab");
await page.getByTestId("outliner-add-button").click();
await page.getByTestId("add-menu-lights").click();
await page.getByTestId("add-menu-spot-light").click();
await setViewportCreationPreview(page, "topLeft", { kind: "entity", entityKind: "spotLight", audioAssetId: null }, { x: -10, y: 4, z: 6 });
await expect(page.getByTestId("viewport-snap-preview-topLeft")).toBeVisible();
await clickViewport(page, "topLeft");
await expect(page.getByTestId("spot-light-angle")).toHaveValue("35");
await page.getByTestId("spot-light-angle").fill("48");
await page.getByTestId("spot-light-angle").press("Tab");
await page.getByTestId("spot-light-direction-y").fill("-0.9");
await page.getByTestId("spot-light-direction-y").press("Tab");
await expect(page.locator('[data-testid^="outliner-entity-"]')).toHaveCount(2);
await page.getByRole("button", { name: "Save Draft" }).click();
await expect(page.getByTestId("status-message")).toContainText("Local draft saved.");
await page.reload();
await page.getByTestId("outliner-add-button").click();
await page.getByTestId("add-menu-assets").click();
await page.getByTestId("add-menu-assets-environments").click();
const reloadedAddMenu = page.getByRole("menu", { name: "Add" });
await expect(reloadedAddMenu.getByRole("menuitem", { name: "skybox-panorama.svg" })).toBeVisible();
await reloadedAddMenu.getByRole("menuitem", { name: "skybox-panorama.svg" }).click();
await expect(page.locator('[data-testid^="outliner-entity-"]')).toHaveCount(2);
await expect(page.getByTestId("viewport-canvas-topLeft")).toHaveCSS("background-image", /url/);
await page.getByTestId("enter-run-mode").click();
await expect(page.getByTestId("runner-shell")).toBeVisible();
await expect(page.getByTestId("runner-shell")).toHaveCSS("background-image", /url/);
expect(pageErrors).toEqual([]);
expect(consoleErrors).toEqual([]);
});

View File

@@ -0,0 +1,47 @@
import { expect, test } from "@playwright/test";
import { beginBoxCreation, clickViewport, getViewportOverlay, getViewportPanel } from "./viewport-test-helpers";
test("orthographic panel controls keep brush authoring and selection behavior intact", async ({ page }) => {
const pageErrors = [];
const consoleErrors = [];
page.on("pageerror", (error) => {
pageErrors.push(error.message);
});
page.on("console", (message) => {
if (message.type() === "error") {
consoleErrors.push(message.text());
}
});
await page.goto("/");
await page.evaluate((storageKey) => {
window.localStorage.removeItem(storageKey);
}, "webeditor3d.scene-document-draft");
await page.reload();
await beginBoxCreation(page);
await clickViewport(page, "topLeft");
await expect(page.getByRole("button", { name: /Whitebox Box 1/ })).toBeVisible();
await expect(page.getByText("1 solid selected (Whitebox Box 1)")).toBeVisible();
await expect(page.getByTestId("viewport-active-panel")).toHaveCount(0);
await expect(page.getByTestId("viewport-panel-topLeft-view-perspective")).toHaveAttribute("aria-pressed", "true");
await expect(getViewportOverlay(page, "topLeft")).toBeVisible();
await expect(page.getByTestId("viewport-selection-mode-topLeft")).toHaveText("Object");
await page.getByTestId("viewport-panel-topLeft-view-top").dispatchEvent("click");
await expect(page.getByTestId("viewport-panel-topLeft-view-top")).toHaveAttribute("aria-pressed", "true");
await expect(page.getByTestId("viewport-selection-mode-topLeft")).toHaveText("Object");
await page.getByTestId("viewport-panel-topLeft-view-front").dispatchEvent("click");
await expect(page.getByTestId("viewport-panel-topLeft-view-front")).toHaveAttribute("aria-pressed", "true");
await expect(page.getByTestId("viewport-selection-mode-topLeft")).toHaveText("Object");
await page.getByTestId("viewport-panel-topLeft-view-side").dispatchEvent("click");
await expect(page.getByTestId("viewport-panel-topLeft-view-side")).toHaveAttribute("aria-pressed", "true");
await expect(page.getByTestId("viewport-selection-mode-topLeft")).toHaveText("Object");
await page.getByTestId("viewport-panel-topLeft-display-authoring").dispatchEvent("click");
await expect(page.getByTestId("viewport-panel-topLeft-display-authoring")).toHaveAttribute("aria-pressed", "true");
await expect(page.getByTestId("viewport-canvas-topLeft")).toHaveCSS("background-color", "rgb(0, 0, 0)");
await expect(getViewportPanel(page, "topLeft")).toHaveAttribute("data-active", "true");
await expect(page.getByText("1 solid selected (Whitebox Box 1)")).toBeVisible();
await page.getByTestId("viewport-panel-topLeft-display-wireframe").dispatchEvent("click");
await expect(page.getByTestId("viewport-panel-topLeft-display-wireframe")).toHaveAttribute("aria-pressed", "true");
await expect(page.getByTestId("viewport-canvas-topLeft")).toHaveCSS("background-color", "rgb(0, 0, 0)");
await expect(page.getByText("1 solid selected (Whitebox Box 1)")).toBeVisible();
expect(pageErrors).toEqual([]);
expect(consoleErrors).toEqual([]);
});

View File

@@ -0,0 +1,40 @@
import { expect, test } from "@playwright/test";
import { clickViewport, setViewportCreationPreview } from "./viewport-test-helpers";
test("user can place PlayerStart, enter run mode, and spawn from it", async ({ page }) => {
const pageErrors = [];
const consoleErrors = [];
page.on("pageerror", (error) => {
pageErrors.push(error.message);
});
page.on("console", (message) => {
if (message.type() === "error") {
consoleErrors.push(message.text());
}
});
await page.goto("/");
await page.evaluate((storageKey) => {
window.localStorage.removeItem(storageKey);
}, "webeditor3d.scene-document-draft");
await page.reload();
await page.getByTestId("outliner-add-button").click();
await page.getByTestId("add-menu-entities").click();
await page.getByTestId("add-menu-player-start").click();
await setViewportCreationPreview(page, "topLeft", { kind: "entity", entityKind: "playerStart", audioAssetId: null }, { x: 0, y: 0, z: 0 });
await clickViewport(page, "topLeft");
await page.getByTestId("player-start-position-x").fill("4");
await page.getByTestId("player-start-position-x").press("Tab");
await page.getByTestId("player-start-position-z").fill("-2");
await page.getByTestId("player-start-position-z").press("Tab");
await page.getByTestId("player-start-yaw").fill("90");
await page.getByTestId("player-start-yaw").press("Tab");
await page.getByTestId("enter-run-mode").click();
await expect(page.getByTestId("runner-shell")).toBeVisible();
await expect(page.getByTestId("runner-spawn-state")).toContainText("Player Start");
await expect(page.getByTestId("runner-player-position")).toContainText("4.00, 0.00, -2.00");
await page.getByTestId("runner-mode-orbit-visitor").click();
await expect(page.getByTestId("runner-mode-orbit-visitor")).toHaveClass(/toolbar__button--active/);
await page.getByTestId("exit-run-mode").click();
await expect(page.getByTestId("viewport-shell")).toBeVisible();
expect(pageErrors).toEqual([]);
expect(consoleErrors).toEqual([]);
});

View File

@@ -0,0 +1,59 @@
import { expect, test } from "@playwright/test";
import { clickViewport, setViewportCreationPreview } from "./viewport-test-helpers";
test("Interactable click prompt can teleport the player in run mode", async ({ page }) => {
const pageErrors = [];
const consoleErrors = [];
page.on("pageerror", (error) => {
pageErrors.push(error.message);
});
page.on("console", (message) => {
if (message.type() === "error") {
consoleErrors.push(message.text());
}
});
await page.goto("/");
await page.evaluate((storageKey) => {
window.localStorage.removeItem(storageKey);
}, "webeditor3d.scene-document-draft");
await page.reload();
await page.getByTestId("outliner-add-button").click();
await page.getByTestId("add-menu-entities").click();
await page.getByTestId("add-menu-player-start").click();
await setViewportCreationPreview(page, "topLeft", { kind: "entity", entityKind: "playerStart", audioAssetId: null }, { x: 0, y: 0, z: 0 });
await clickViewport(page, "topLeft");
await page.getByTestId("outliner-add-button").click();
await page.getByTestId("add-menu-entities").click();
await page.getByTestId("add-menu-interactable").click();
await setViewportCreationPreview(page, "topLeft", { kind: "entity", entityKind: "interactable", audioAssetId: null }, { x: 0, y: 0, z: 0 });
await clickViewport(page, "topLeft");
await page.getByTestId("interactable-position-y").fill("1");
await page.getByTestId("interactable-position-y").press("Tab");
await page.getByTestId("interactable-position-z").fill("1");
await page.getByTestId("interactable-position-z").press("Tab");
await page.getByTestId("interactable-radius").fill("4");
await page.getByTestId("interactable-radius").press("Tab");
await page.getByTestId("interactable-prompt").fill("Use Console");
await page.getByTestId("interactable-prompt").press("Tab");
await page.getByTestId("outliner-add-button").click();
await page.getByTestId("add-menu-entities").click();
await page.getByTestId("add-menu-teleport-target").click();
await setViewportCreationPreview(page, "topLeft", { kind: "entity", entityKind: "teleportTarget", audioAssetId: null }, { x: 0, y: 0, z: 0 });
await clickViewport(page, "topLeft");
await page.getByTestId("teleportTarget-position-x").fill("6");
await page.getByTestId("teleportTarget-position-x").press("Tab");
await page
.locator('[data-testid^="outliner-entity-"]')
.filter({ hasText: "Interactable" })
.first()
.click();
await page.getByTestId("add-interactable-teleport-link").click();
await page.getByRole("button", { name: "First Person" }).first().click();
await page.getByTestId("enter-run-mode").click();
await expect(page.getByTestId("runner-shell")).toBeVisible();
await expect(page.getByTestId("runner-interaction-prompt")).toBeVisible();
await expect(page.getByTestId("runner-interaction-prompt-text")).toContainText("Use Console");
await page.locator('[data-testid="runner-shell"] canvas').click();
await expect(page.getByTestId("runner-player-position")).toContainText("6.00,");
expect(pageErrors).toEqual([]);
expect(consoleErrors).toEqual([]);
});

View File

@@ -0,0 +1,48 @@
import { expect, test } from "@playwright/test";
import { clickViewport, setViewportCreationPreview } from "./viewport-test-helpers";
test("Trigger Volume enter can teleport the player to a Teleport Target", async ({ page }) => {
const pageErrors = [];
const consoleErrors = [];
page.on("pageerror", (error) => {
pageErrors.push(error.message);
});
page.on("console", (message) => {
if (message.type() === "error") {
consoleErrors.push(message.text());
}
});
await page.goto("/");
await page.evaluate((storageKey) => {
window.localStorage.removeItem(storageKey);
}, "webeditor3d.scene-document-draft");
await page.reload();
await page.getByTestId("outliner-add-button").click();
await page.getByTestId("add-menu-entities").click();
await page.getByTestId("add-menu-player-start").click();
await setViewportCreationPreview(page, "topLeft", { kind: "entity", entityKind: "playerStart", audioAssetId: null }, { x: 0, y: 0, z: 0 });
await clickViewport(page, "topLeft");
await page.getByTestId("outliner-add-button").click();
await page.getByTestId("add-menu-entities").click();
await page.getByTestId("add-menu-trigger-volume").click();
await setViewportCreationPreview(page, "topLeft", { kind: "entity", entityKind: "triggerVolume", audioAssetId: null }, { x: 0, y: 0, z: 0 });
await clickViewport(page, "topLeft");
await page.getByTestId("outliner-add-button").click();
await page.getByTestId("add-menu-entities").click();
await page.getByTestId("add-menu-teleport-target").click();
await setViewportCreationPreview(page, "topLeft", { kind: "entity", entityKind: "teleportTarget", audioAssetId: null }, { x: 0, y: 0, z: 0 });
await clickViewport(page, "topLeft");
await page.getByTestId("teleportTarget-position-x").fill("6");
await page.getByTestId("teleportTarget-position-x").press("Tab");
await page
.locator('[data-testid^="outliner-entity-"]')
.filter({ hasText: "Trigger Volume" })
.first()
.click();
await page.getByTestId("add-trigger-teleport-link").click();
await page.getByRole("button", { name: "First Person" }).first().click();
await page.getByTestId("enter-run-mode").click();
await expect(page.getByTestId("runner-shell")).toBeVisible();
await expect(page.getByTestId("runner-player-position")).toContainText("6.00,");
expect(pageErrors).toEqual([]);
expect(consoleErrors).toEqual([]);
});

View File

@@ -0,0 +1,134 @@
import { expect, test } from "@playwright/test";
import { beginBoxCreation, clickViewport, getEditorStoreSnapshot, getViewportPanel, setSharedBoxCreationPreview } from "./viewport-test-helpers";
test("quad viewport layout shows four linked panels with shared selection and active panel state", async ({ page }) => {
const pageErrors = [];
const consoleErrors = [];
page.on("pageerror", (error) => {
pageErrors.push(error.message);
});
page.on("console", (message) => {
if (message.type() === "error") {
consoleErrors.push(message.text());
}
});
await page.goto("/");
await page.evaluate((storageKey) => {
window.localStorage.removeItem(storageKey);
}, "webeditor3d.scene-document-draft");
await page.reload();
await beginBoxCreation(page);
await clickViewport(page, "topLeft");
await expect(page.getByRole("button", { name: /Whitebox Box 1/ })).toBeVisible();
await expect(page.getByText("1 solid selected (Whitebox Box 1)")).toBeVisible();
await page.getByTestId("viewport-layout-quad").click();
await expect(page.getByTestId("viewport-panel-topLeft")).toBeVisible();
await expect(page.getByTestId("viewport-panel-topRight")).toBeVisible();
await expect(page.getByTestId("viewport-panel-bottomLeft")).toBeVisible();
await expect(page.getByTestId("viewport-panel-bottomRight")).toBeVisible();
const initialLayoutSnapshot = await getEditorStoreSnapshot(page);
expect(initialLayoutSnapshot.viewportQuadSplit).toEqual({
x: 0.5,
y: 0.5
});
const dragSplitter = async (testId, deltaX, deltaY) => {
const splitter = page.getByTestId(testId);
const box = await splitter.boundingBox();
if (box === null) {
throw new Error(`Missing splitter handle: ${testId}`);
}
await page.mouse.move(box.x + box.width * 0.5, box.y + box.height * 0.5);
await page.mouse.down();
await page.mouse.move(box.x + box.width * 0.5 + deltaX, box.y + box.height * 0.5 + deltaY);
await page.mouse.up();
};
await dragSplitter("viewport-quad-splitter-center", 80, 48);
const afterCenterResizeSnapshot = await getEditorStoreSnapshot(page);
expect(afterCenterResizeSnapshot.viewportQuadSplit.x).toBeGreaterThan(0.5);
expect(afterCenterResizeSnapshot.viewportQuadSplit.y).toBeGreaterThan(0.5);
await dragSplitter("viewport-quad-splitter-vertical", -60, 0);
const afterVerticalResizeSnapshot = await getEditorStoreSnapshot(page);
expect(afterVerticalResizeSnapshot.viewportQuadSplit.x).toBeLessThan(afterCenterResizeSnapshot.viewportQuadSplit.x);
expect(Math.abs(afterVerticalResizeSnapshot.viewportQuadSplit.y - afterCenterResizeSnapshot.viewportQuadSplit.y)).toBeLessThan(0.02);
await dragSplitter("viewport-quad-splitter-horizontal", 0, -40);
const afterHorizontalResizeSnapshot = await getEditorStoreSnapshot(page);
expect(Math.abs(afterHorizontalResizeSnapshot.viewportQuadSplit.x - afterVerticalResizeSnapshot.viewportQuadSplit.x)).toBeLessThan(0.02);
expect(afterHorizontalResizeSnapshot.viewportQuadSplit.y).toBeLessThan(afterVerticalResizeSnapshot.viewportQuadSplit.y);
await expect(page.getByTestId("viewport-panel-topLeft-view-perspective")).toHaveAttribute("aria-pressed", "true");
await expect(page.getByTestId("viewport-panel-topLeft-display-normal")).toHaveAttribute("aria-pressed", "true");
await expect(page.getByTestId("viewport-panel-topRight-view-top")).toHaveAttribute("aria-pressed", "true");
await expect(page.getByTestId("viewport-panel-topRight-display-authoring")).toHaveAttribute("aria-pressed", "true");
await expect(page.getByTestId("viewport-canvas-topRight")).toHaveCSS("background-color", "rgb(0, 0, 0)");
await expect(page.getByTestId("viewport-panel-bottomLeft-view-front")).toHaveAttribute("aria-pressed", "true");
await expect(page.getByTestId("viewport-panel-bottomLeft-display-authoring")).toHaveAttribute("aria-pressed", "true");
await expect(page.getByTestId("viewport-panel-bottomRight-view-side")).toHaveAttribute("aria-pressed", "true");
await expect(page.getByTestId("viewport-panel-bottomRight-display-authoring")).toHaveAttribute("aria-pressed", "true");
for (const panelId of ["topLeft", "topRight", "bottomLeft", "bottomRight"]) {
await expect(getViewportPanel(page, panelId).locator(".viewport-panel__subtitle")).toHaveCount(0);
await expect(getViewportPanel(page, panelId).locator(".viewport-canvas__overlay-text")).toHaveCount(0);
}
await setSharedBoxCreationPreview(page, "topLeft", { x: 4, y: 0, z: 8 });
const initialSnapshot = await getEditorStoreSnapshot(page);
expect(initialSnapshot).toMatchObject({
viewportTransientState: {
toolPreview: {
kind: "create",
sourcePanelId: "topLeft",
target: {
kind: "box-brush"
},
center: {
x: 4,
y: 0,
z: 8
}
}
}
});
await getViewportPanel(page, "topRight").click({ position: { x: 16, y: 16 }, force: true });
await page.getByTestId("viewport-panel-topRight-view-side").dispatchEvent("click");
await expect(getViewportPanel(page, "topRight")).toHaveAttribute("data-active", "true");
await expect(page.getByTestId("viewport-panel-topRight-view-side")).toHaveAttribute("aria-pressed", "true");
await expect(page.getByRole("button", { name: /Whitebox Box 1/ })).toBeVisible();
const transferredSnapshot = await getEditorStoreSnapshot(page);
expect(transferredSnapshot).toMatchObject({
viewportTransientState: {
toolPreview: {
kind: "create",
sourcePanelId: "topLeft",
target: {
kind: "box-brush"
},
center: {
x: 4,
y: 0,
z: 8
}
}
}
});
await getViewportPanel(page, "topLeft").click({ position: { x: 16, y: 16 }, force: true });
await page.getByTestId("viewport-panel-topLeft-display-authoring").dispatchEvent("click");
await expect(getViewportPanel(page, "topLeft")).toHaveAttribute("data-active", "true");
await expect(page.getByTestId("viewport-panel-topLeft-display-authoring")).toHaveAttribute("aria-pressed", "true");
await expect(page.getByTestId("viewport-canvas-topLeft")).toHaveCSS("background-color", "rgb(0, 0, 0)");
await expect(page.getByText("1 solid selected (Whitebox Box 1)")).toBeVisible();
const finalSnapshot = await getEditorStoreSnapshot(page);
expect(finalSnapshot).toMatchObject({
viewportTransientState: {
toolPreview: {
kind: "create",
sourcePanelId: "topLeft",
target: {
kind: "box-brush"
},
center: {
x: 4,
y: 0,
z: 8
}
}
}
});
expect(pageErrors).toEqual([]);
expect(consoleErrors).toEqual([]);
});

View File

@@ -0,0 +1,105 @@
export const DEFAULT_VIEWPORT_PANEL_ID = "topLeft";
export function getViewportPanel(page, panelId = DEFAULT_VIEWPORT_PANEL_ID) {
return page.getByTestId(`viewport-panel-${panelId}`);
}
export function getViewportCanvas(page, panelId = DEFAULT_VIEWPORT_PANEL_ID) {
return getViewportPanel(page, panelId).locator("canvas");
}
export function getViewportOverlay(page, panelId = DEFAULT_VIEWPORT_PANEL_ID) {
return page.getByTestId(`viewport-overlay-${panelId}`);
}
export async function getEditorStoreSnapshot(page) {
return page.evaluate(() => {
const store = window.__webeditor3dEditorStore;
if (store === undefined) {
throw new Error("Editor store debug hook is unavailable.");
}
return store.getState();
});
}
export async function getViewportToolPreview(page) {
const snapshot = await getEditorStoreSnapshot(page);
return snapshot.viewportTransientState.toolPreview;
}
export async function replaceSceneDocument(page, document) {
await page.evaluate((nextDocument) => {
const store = window.__webeditor3dEditorStore;
if (store === undefined) {
throw new Error("Editor store debug hook is unavailable.");
}
store.replaceDocument(nextDocument);
}, document);
}
export async function setViewportCreationPreview(page, panelId, target, center) {
await page.evaluate(({ sourcePanelId, nextTarget, nextCenter }) => {
const store = window.__webeditor3dEditorStore;
if (store === undefined) {
throw new Error("Editor store debug hook is unavailable.");
}
store.setViewportToolPreview({
kind: "create",
sourcePanelId,
target: nextTarget,
center: nextCenter
});
}, {
sourcePanelId: panelId,
nextTarget: target,
nextCenter: center
});
}
export async function clearViewportCreationPreview(page) {
await page.evaluate(() => {
const store = window.__webeditor3dEditorStore;
if (store === undefined) {
throw new Error("Editor store debug hook is unavailable.");
}
store.setViewportToolPreview({
kind: "none"
});
});
}
export async function beginBoxCreation(page) {
await page.getByTestId("outliner-add-button").click();
await page.getByTestId("add-menu-box").click();
}
export async function clickViewport(page, panelId = DEFAULT_VIEWPORT_PANEL_ID) {
const viewportPanel = getViewportPanel(page, panelId);
await viewportPanel.click({ position: { x: 16, y: 16 }, force: true });
const fallbackButton = viewportPanel.getByTestId(`viewport-fallback-create-${panelId}`);
if ((await fallbackButton.count()) > 0) {
await fallbackButton.click();
return;
}
const viewportCanvas = getViewportCanvas(page, panelId);
if ((await viewportCanvas.count()) > 0) {
await viewportCanvas.click();
return;
}
}
export async function clickViewportAtRatio(page, panelId, xRatio, yRatio) {
const viewportCanvas = getViewportCanvas(page, panelId);
if ((await viewportCanvas.count()) > 0) {
const canvasBox = await viewportCanvas.boundingBox();
if (canvasBox !== null) {
await viewportCanvas.click({
position: {
x: canvasBox.width * xRatio,
y: canvasBox.height * yRatio
}
});
return;
}
}
const viewportPanel = getViewportPanel(page, panelId);
const box = await viewportPanel.boundingBox();
if (box === null) {
throw new Error(`Missing viewport panel for ${panelId}.`);
}
await page.mouse.click(box.x + box.width * xRatio, box.y + box.height * yRatio);
}
export async function setSharedBoxCreationPreview(page, panelId, center) {
return setViewportCreationPreview(page, panelId, {
kind: "box-brush"
}, center);
}

View File

@@ -0,0 +1,121 @@
import { expect, test } from "@playwright/test";
import { createBoxBrush } from "../../src/document/brushes";
import { createEmptySceneDocument } from "../../src/document/scene-document";
import { clickViewportAtRatio, getEditorStoreSnapshot, replaceSceneDocument } from "./viewport-test-helpers";
test("whitebox component selection modes keep object picking intentional across perspective and orthographic panes", async ({ page }) => {
const pageErrors = [];
const consoleErrors = [];
page.on("pageerror", (error) => {
pageErrors.push(error.message);
});
page.on("console", (message) => {
if (message.type() === "error") {
consoleErrors.push(message.text());
}
});
await page.goto("/");
await page.evaluate((storageKey) => {
window.localStorage.removeItem(storageKey);
}, "webeditor3d.scene-document-draft");
await page.reload();
test.skip((await page.getByText("Viewport Unavailable").count()) > 0, "WebGL is unavailable in this Playwright environment.");
const brush = createBoxBrush({
id: "brush-selection-modes-main",
name: "Selection Fixture",
center: {
x: 0,
y: 1,
z: 0
},
size: {
x: 2,
y: 2,
z: 2
}
});
await replaceSceneDocument(page, {
...createEmptySceneDocument({ name: "Selection Modes Scene" }),
brushes: {
[brush.id]: brush
}
});
await page.evaluate(({ target }) => {
const store = window.__webeditor3dEditorStore;
if (store === undefined) {
throw new Error("Editor store debug hook is unavailable.");
}
const topLeftCameraState = store.getState().viewportPanels.topLeft.cameraState;
store.setViewportPanelCameraState("topLeft", {
...topLeftCameraState,
target,
perspectiveOrbit: {
radius: 4.5,
theta: 0.72,
phi: 1.08
}
});
}, { target: brush.center });
await expect(page.getByTestId("viewport-selection-mode-topLeft")).toHaveText("Object");
await clickViewportAtRatio(page, "topLeft", 0.5, 0.52);
let snapshot = await getEditorStoreSnapshot(page);
expect(snapshot.whiteboxSelectionMode).toBe("object");
expect(snapshot.selection).toMatchObject({
kind: "brushes",
ids: [brush.id]
});
await page.getByTestId("viewport-layout-quad").click();
await page.evaluate(({ target }) => {
const store = window.__webeditor3dEditorStore;
if (store === undefined) {
throw new Error("Editor store debug hook is unavailable.");
}
const topRightCameraState = store.getState().viewportPanels.topRight.cameraState;
store.setViewportPanelViewMode("topRight", "top");
store.setViewportPanelCameraState("topRight", {
...topRightCameraState,
target,
orthographicZoom: 8
});
}, { target: brush.center });
await page.getByTestId("whitebox-selection-mode-face").click();
await expect(page.getByTestId("viewport-selection-mode-topRight")).toHaveText("Face");
await clickViewportAtRatio(page, "topRight", 0.5, 0.5);
snapshot = await getEditorStoreSnapshot(page);
expect(snapshot.whiteboxSelectionMode).toBe("face");
expect(snapshot.selection).toMatchObject({
kind: "brushFace",
brushId: brush.id,
faceId: "posY"
});
await expect(page.getByTestId("viewport-panel-active-badge-topRight")).toBeVisible();
await page.getByTestId("whitebox-selection-mode-edge").click();
await expect(page.getByTestId("viewport-selection-mode-topRight")).toHaveText("Edge");
await clickViewportAtRatio(page, "topRight", 0.5, 0.12);
snapshot = await getEditorStoreSnapshot(page);
expect(snapshot.whiteboxSelectionMode).toBe("edge");
expect(snapshot.selection).toMatchObject({
kind: "brushEdge",
brushId: brush.id,
edgeId: "edgeX_posY_negZ"
});
await page.getByTestId("whitebox-selection-mode-vertex").click();
await expect(page.getByTestId("viewport-selection-mode-topRight")).toHaveText("Vertex");
await clickViewportAtRatio(page, "topRight", 0.88, 0.12);
snapshot = await getEditorStoreSnapshot(page);
expect(snapshot.whiteboxSelectionMode).toBe("vertex");
expect(snapshot.selection).toMatchObject({
kind: "brushVertex",
brushId: brush.id,
vertexId: "posX_posY_negZ"
});
await page.getByTestId("whitebox-selection-mode-object").click();
await clickViewportAtRatio(page, "topRight", 0.5, 0.5);
snapshot = await getEditorStoreSnapshot(page);
expect(snapshot.whiteboxSelectionMode).toBe("object");
expect(snapshot.selection).toMatchObject({
kind: "brushes",
ids: [brush.id]
});
expect(pageErrors).toEqual([]);
expect(consoleErrors).toEqual([]);
});

View File

@@ -0,0 +1,52 @@
import { expect, test } from "@playwright/test";
async function setColorInput(locator, value) {
await locator.evaluate((element, nextValue) => {
const input = element;
input.value = nextValue;
input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(new Event("change", { bubbles: true }));
}, value);
}
test("world environment settings persist and carry into the runner", async ({ page }) => {
const pageErrors = [];
const consoleErrors = [];
page.on("pageerror", (error) => {
pageErrors.push(error.message);
});
page.on("console", (message) => {
if (message.type() === "error") {
consoleErrors.push(message.text());
}
});
await page.goto("/");
await page.evaluate((storageKey) => {
window.localStorage.removeItem(storageKey);
}, "webeditor3d.scene-document-draft");
await page.reload();
await page.getByTestId("world-background-mode-gradient").click();
await setColorInput(page.getByTestId("world-background-top-color"), "#6a87ab");
await setColorInput(page.getByTestId("world-background-bottom-color"), "#151b23");
await setColorInput(page.getByTestId("world-ambient-color"), "#d4e2ff");
await page.getByTestId("world-ambient-intensity").fill("0.45");
await page.getByTestId("world-ambient-intensity").press("Tab");
await page.getByTestId("world-sun-intensity").fill("2.25");
await page.getByTestId("world-sun-intensity").press("Tab");
await page.getByTestId("world-sun-direction-x").fill("-1");
await page.getByTestId("world-sun-direction-x").press("Tab");
await expect(page.getByTestId("world-background-mode-value")).toContainText("Vertical Gradient");
await expect(page.getByTestId("viewport-canvas-topLeft")).toHaveCSS("background-image", /linear-gradient/);
await page.getByRole("button", { name: "Save Draft" }).click();
await page.getByTestId("world-background-mode-solid").click();
await setColorInput(page.getByTestId("world-background-solid-color"), "#223344");
await page.getByTestId("world-ambient-intensity").fill("0.9");
await page.getByTestId("world-ambient-intensity").press("Tab");
await page.getByRole("button", { name: "Load Draft" }).click();
await expect(page.getByTestId("world-background-mode-value")).toContainText("Vertical Gradient");
await expect(page.getByTestId("world-ambient-intensity")).toHaveValue("0.45");
await expect(page.getByTestId("viewport-canvas-topLeft")).toHaveCSS("background-image", /linear-gradient/);
await page.getByTestId("enter-run-mode").click();
await expect(page.getByTestId("runner-shell")).toBeVisible();
await expect(page.getByTestId("runner-shell")).toHaveCSS("background-image", /linear-gradient/);
expect(pageErrors).toEqual([]);
expect(consoleErrors).toEqual([]);
});