Files
webeditor3d/src/viewport-three/ViewportCanvas.tsx

269 lines
9.4 KiB
TypeScript

import { useEffect, useRef, useState } from "react";
import type { LoadedModelAsset } from "../assets/gltf-model-import";
import type { LoadedImageAsset } from "../assets/image-assets";
import type { ProjectAssetRecord } from "../assets/project-assets";
import type { EditorSelection } from "../core/selection";
import type { ToolMode } from "../core/tool-mode";
import type { Vec3 } from "../core/vector";
import { DEFAULT_BOX_BRUSH_CENTER, DEFAULT_BOX_BRUSH_SIZE } from "../document/brushes";
import type { SceneDocument } from "../document/scene-document";
import type { WorldSettings } from "../document/world-settings";
import { createWorldBackgroundStyle } from "../shared-ui/world-background-style";
import { getViewportPanelLabel, type ViewportDisplayMode, type ViewportLayoutMode, type ViewportPanelId } from "./viewport-layout";
import {
getViewportViewModeControlHint,
getViewportViewModeGridPlaneLabel,
getViewportViewModeLabel,
type ViewportViewMode
} from "./viewport-view-modes";
import type { PlacementViewportToolPreview, ViewportToolPreview } from "./viewport-transient-state";
import { ViewportHost } from "./viewport-host";
interface ViewportCanvasProps {
panelId: ViewportPanelId;
world: WorldSettings;
sceneDocument: SceneDocument;
projectAssets: Record<string, ProjectAssetRecord>;
loadedModelAssets: Record<string, LoadedModelAsset>;
loadedImageAssets: Record<string, LoadedImageAsset>;
selection: EditorSelection;
toolMode: ToolMode;
toolPreview: ViewportToolPreview;
viewMode: ViewportViewMode;
displayMode: ViewportDisplayMode;
layoutMode: ViewportLayoutMode;
isActivePanel: boolean;
focusRequestId: number;
focusSelection: EditorSelection;
onSelectionChange(selection: EditorSelection): void;
onCreateBoxBrush(center: Vec3): void;
onCommitPlacement(toolPreview: PlacementViewportToolPreview): void;
onToolPreviewChange(toolPreview: ViewportToolPreview): void;
}
function formatVec3(vector: Vec3): string {
return `${vector.x}, ${vector.y}, ${vector.z}`;
}
function getViewportOverlayText(
toolMode: ToolMode,
viewMode: ViewportViewMode,
displayMode: ViewportDisplayMode,
layoutMode: ViewportLayoutMode
): string | null {
if (layoutMode === "quad") {
return null;
}
if (toolMode === "box-create") {
return `Hover the ${getViewportViewModeGridPlaneLabel(viewMode)} grid to place a ${DEFAULT_BOX_BRUSH_SIZE.x} x ${DEFAULT_BOX_BRUSH_SIZE.y} x ${DEFAULT_BOX_BRUSH_SIZE.z} box. ${getViewportViewModeControlHint(viewMode)}`;
}
if (toolMode === "place") {
return `Hover the ${getViewportViewModeGridPlaneLabel(viewMode)} grid to position the preview. Click to place it. ${getViewportViewModeControlHint(viewMode)}`;
}
return `${displayMode === "authoring" ? "Authoring view" : `${getViewportViewModeLabel(viewMode)} view`} on the ${getViewportViewModeGridPlaneLabel(viewMode)} grid. ${getViewportViewModeControlHint(viewMode)}`;
}
export function ViewportCanvas({
panelId,
world,
sceneDocument,
projectAssets,
loadedModelAssets,
loadedImageAssets,
selection,
toolMode,
toolPreview,
viewMode,
displayMode,
layoutMode,
isActivePanel,
focusRequestId,
focusSelection,
onSelectionChange,
onCreateBoxBrush,
onCommitPlacement,
onToolPreviewChange
}: ViewportCanvasProps) {
const containerRef = useRef<HTMLDivElement | null>(null);
const hostRef = useRef<ViewportHost | null>(null);
const [viewportMessage, setViewportMessage] = useState<string | null>(null);
useEffect(() => {
const container = containerRef.current;
if (container === null) {
return;
}
const testCanvas = document.createElement("canvas");
const hasWebGl =
testCanvas.getContext("webgl2") !== null ||
testCanvas.getContext("webgl") !== null ||
testCanvas.getContext("experimental-webgl") !== null;
if (!hasWebGl) {
setViewportMessage("WebGL is unavailable in this browser environment. The viewport shell is visible, but rendering is disabled.");
return;
}
try {
const viewportHost = new ViewportHost();
hostRef.current = viewportHost;
viewportHost.mount(container);
setViewportMessage(null);
return () => {
viewportHost.dispose();
hostRef.current = null;
};
} catch (error) {
const message = error instanceof Error ? error.message : "Viewport initialization failed.";
setViewportMessage(`Viewport initialization failed: ${message}`);
return;
}
}, []);
useEffect(() => {
hostRef.current?.updateWorld(world);
}, [world]);
useEffect(() => {
hostRef.current?.updateAssets(projectAssets, loadedModelAssets, loadedImageAssets);
}, [projectAssets, loadedModelAssets, loadedImageAssets]);
useEffect(() => {
hostRef.current?.updateDocument(sceneDocument, selection);
}, [sceneDocument, selection]);
useEffect(() => {
hostRef.current?.setViewMode(viewMode);
}, [viewMode]);
useEffect(() => {
hostRef.current?.setDisplayMode(displayMode);
}, [displayMode]);
useEffect(() => {
hostRef.current?.setBrushSelectionChangeHandler(onSelectionChange);
}, [onSelectionChange]);
useEffect(() => {
hostRef.current?.setCreateBoxBrushHandler(onCreateBoxBrush);
}, [onCreateBoxBrush]);
useEffect(() => {
hostRef.current?.setBoxCreatePreviewChangeHandler((center) => {
onToolPreviewChange(
center === null
? {
kind: "none"
}
: {
kind: "box-create",
sourcePanelId: panelId,
center
}
);
});
}, [onToolPreviewChange, panelId]);
useEffect(() => {
hostRef.current?.setPlacementPreviewChangeHandler(onToolPreviewChange);
}, [onToolPreviewChange]);
useEffect(() => {
hostRef.current?.setPlacementCommitHandler(onCommitPlacement);
}, [onCommitPlacement]);
useEffect(() => {
hostRef.current?.setToolMode(toolMode);
}, [toolMode]);
useEffect(() => {
hostRef.current?.setBoxCreatePreview(toolMode === "box-create" && toolPreview.kind === "box-create" ? toolPreview.center : null);
}, [toolMode, toolPreview]);
useEffect(() => {
hostRef.current?.setPlacementPreview(toolMode === "place" && toolPreview.kind === "placement" ? toolPreview : null);
}, [toolMode, toolPreview]);
useEffect(() => {
if (focusRequestId === 0) {
return;
}
hostRef.current?.focusSelection(sceneDocument, focusSelection);
}, [focusRequestId, focusSelection, sceneDocument]);
const overlayText = getViewportOverlayText(toolMode, viewMode, displayMode, layoutMode);
const previewVisible =
(toolMode === "box-create" && toolPreview.kind === "box-create" && toolPreview.center !== null) ||
(toolMode === "place" && toolPreview.kind === "placement" && toolPreview.center !== null);
return (
<div
ref={containerRef}
className={`viewport-canvas viewport-canvas--${toolMode} viewport-canvas--${viewMode} viewport-canvas--${displayMode} viewport-canvas--${layoutMode} ${isActivePanel ? "viewport-canvas--active" : ""}`}
data-testid={`viewport-canvas-${panelId}`}
aria-label={`${getViewportPanelLabel(panelId)} editor viewport`}
style={
displayMode === "authoring"
? {
backgroundColor: "#000000",
backgroundImage: "none"
}
: createWorldBackgroundStyle(world.background, world.background.mode === "image" ? loadedImageAssets[world.background.assetId]?.sourceUrl ?? null : null)
}
>
<div className="viewport-canvas__overlay" data-testid={`viewport-overlay-${panelId}`}>
<div className="viewport-canvas__overlay-badges">
<div className="viewport-canvas__overlay-badge">
{toolMode === "box-create" ? "Box Create" : toolMode === "place" ? "Place" : "Select"}
</div>
<div className="viewport-canvas__overlay-badge viewport-canvas__overlay-badge--view">{getViewportViewModeLabel(viewMode)}</div>
<div className="viewport-canvas__overlay-badge viewport-canvas__overlay-badge--display">
{displayMode === "authoring" ? "Authoring" : "Lit"}
</div>
</div>
{overlayText === null ? null : <div className="viewport-canvas__overlay-text">{overlayText}</div>}
{!previewVisible ? null : (
<div className="viewport-canvas__overlay-preview" data-testid={`viewport-snap-preview-${panelId}`}>
Preview: {formatVec3(toolPreview.center)}
</div>
)}
</div>
{viewportMessage === null ? null : (
<div className="viewport-canvas__fallback" role="status">
<div className="viewport-canvas__fallback-title">Viewport Unavailable</div>
<div>{viewportMessage}</div>
{toolMode !== "box-create" && toolMode !== "place" ? null : (
<button
className="toolbar__button toolbar__button--accent"
type="button"
data-testid={
toolMode === "place" ? `viewport-fallback-place-${panelId}` : `viewport-fallback-create-box-${panelId}`
}
onClick={() => {
if (toolMode === "place" && toolPreview.kind === "placement") {
onCommitPlacement(toolPreview);
return;
}
onCreateBoxBrush(DEFAULT_BOX_BRUSH_CENTER);
}}
>
{toolMode === "place" ? "Place Preview at Default Position" : "Create Default Box"}
</button>
)}
</div>
)}
</div>
);
}