Add viewport quad split functionality and related utilities

This commit is contained in:
2026-04-03 01:26:21 +02:00
parent e88cd7e795
commit d8cc3a1c46

View File

@@ -171,7 +171,8 @@ import {
getViewportLayoutModeLabel,
type ViewportDisplayMode,
type ViewportLayoutMode,
type ViewportPanelId
type ViewportPanelId,
type ViewportQuadSplit
} from "../viewport-three/viewport-layout";
import type { EditorStore } from "./editor-store";
import { useEditorStoreState } from "./use-editor-store";
@@ -204,6 +205,10 @@ const FACE_LABELS: Record<BoxFaceId, string> = {
};
const STARTER_MATERIAL_ORDER = new Map(STARTER_MATERIAL_LIBRARY.map((material, index) => [material.id, index]));
const MIN_VIEWPORT_QUAD_SPLIT = 0.2;
const MAX_VIEWPORT_QUAD_SPLIT = 0.8;
type ViewportQuadResizeMode = "vertical" | "horizontal" | "center";
function formatVec3(vector: Vec3): string {
return `${vector.x}, ${vector.y}, ${vector.z}`;
@@ -213,6 +218,28 @@ function formatDiagnosticCount(count: number, label: string): string {
return `${count} ${label}${count === 1 ? "" : "s"}`;
}
function clampViewportQuadSplitValue(value: number): number {
return Math.min(MAX_VIEWPORT_QUAD_SPLIT, Math.max(MIN_VIEWPORT_QUAD_SPLIT, value));
}
function createViewportQuadPanelsStyle(viewportQuadSplit: ViewportQuadSplit): CSSProperties {
return {
"--viewport-quad-split-x": String(viewportQuadSplit.x),
"--viewport-quad-split-y": String(viewportQuadSplit.y)
} as CSSProperties;
}
function getViewportQuadResizeCursor(resizeMode: ViewportQuadResizeMode): string {
switch (resizeMode) {
case "vertical":
return "col-resize";
case "horizontal":
return "row-resize";
case "center":
return "move";
}
}
function createVec2Draft(vector: Vec2): Vec2Draft {
return {
x: String(vector.x),
@@ -884,13 +911,16 @@ export function App({ store, initialStatusMessage }: AppProps) {
const importModelInputRef = useRef<HTMLInputElement | null>(null);
const importBackgroundImageInputRef = useRef<HTMLInputElement | null>(null);
const importAudioInputRef = useRef<HTMLInputElement | null>(null);
const viewportPanelsRef = useRef<HTMLDivElement | null>(null);
const loadedModelAssetsRef = useRef<Record<string, LoadedModelAsset>>({});
const loadedImageAssetsRef = useRef<Record<string, LoadedImageAsset>>({});
const loadedAudioAssetsRef = useRef<Record<string, LoadedAudioAsset>>({});
const viewportQuadSplitRef = useRef(editorState.viewportQuadSplit);
const lastPointerPositionRef = useRef<HierarchicalMenuPosition>({
x: Math.round(window.innerWidth * 0.5),
y: Math.round(window.innerHeight * 0.5)
});
const [viewportQuadResizeMode, setViewportQuadResizeMode] = useState<ViewportQuadResizeMode | null>(null);
const documentValidation = validateSceneDocument(editorState.document);
const runValidation = validateRuntimeSceneBuild(editorState.document, preferredNavigationMode);
const diagnostics = [...documentValidation.errors, ...documentValidation.warnings, ...runValidation.errors, ...runValidation.warnings];
@@ -1072,6 +1102,10 @@ export function App({ store, initialStatusMessage }: AppProps) {
loadedAudioAssetsRef.current = loadedAudioAssets;
}, [loadedAudioAssets]);
useEffect(() => {
viewportQuadSplitRef.current = editorState.viewportQuadSplit;
}, [editorState.viewportQuadSplit]);
useEffect(() => {
let cancelled = false;
@@ -1381,6 +1415,69 @@ export function App({ store, initialStatusMessage }: AppProps) {
};
}, [activePanelId, addMenuPosition, brushList.length, editorState.selection, editorState.toolMode, entityList.length]);
useEffect(() => {
if (layoutMode === "quad" || viewportQuadResizeMode === null) {
return;
}
setViewportQuadResizeMode(null);
}, [layoutMode, viewportQuadResizeMode]);
useEffect(() => {
if (layoutMode !== "quad" || viewportQuadResizeMode === null) {
return;
}
const previousCursor = document.body.style.cursor;
const previousUserSelect = document.body.style.userSelect;
document.body.style.cursor = getViewportQuadResizeCursor(viewportQuadResizeMode);
document.body.style.userSelect = "none";
const handlePointerMove = (event: globalThis.PointerEvent) => {
const viewportPanels = viewportPanelsRef.current;
if (viewportPanels === null) {
return;
}
const rect = viewportPanels.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) {
return;
}
const nextViewportQuadSplit = {
...viewportQuadSplitRef.current
};
if (viewportQuadResizeMode !== "horizontal") {
nextViewportQuadSplit.x = clampViewportQuadSplitValue((event.clientX - rect.left) / rect.width);
}
if (viewportQuadResizeMode !== "vertical") {
nextViewportQuadSplit.y = clampViewportQuadSplitValue((event.clientY - rect.top) / rect.height);
}
store.setViewportQuadSplit(nextViewportQuadSplit);
};
const stopViewportResize = () => {
setViewportQuadResizeMode(null);
};
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", stopViewportResize);
window.addEventListener("pointercancel", stopViewportResize);
return () => {
document.body.style.cursor = previousCursor;
document.body.style.userSelect = previousUserSelect;
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", stopViewportResize);
window.removeEventListener("pointercancel", stopViewportResize);
};
}, [layoutMode, store, viewportQuadResizeMode]);
useEffect(() => {
if (editorState.toolMode !== "play") {
return;
@@ -1495,6 +1592,43 @@ export function App({ store, initialStatusMessage }: AppProps) {
setStatusMessage(`Set the viewport panel to ${getViewportDisplayModeLabel(nextDisplayMode)} display.`);
};
const handleViewportQuadResizeStart =
(resizeMode: ViewportQuadResizeMode) => (event: ReactPointerEvent<HTMLDivElement>) => {
if (layoutMode !== "quad") {
return;
}
const viewportPanels = viewportPanelsRef.current;
if (viewportPanels === null) {
return;
}
event.preventDefault();
event.stopPropagation();
blurActiveTextEntry();
const rect = viewportPanels.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) {
const nextViewportQuadSplit = {
...viewportQuadSplitRef.current
};
if (resizeMode !== "horizontal") {
nextViewportQuadSplit.x = clampViewportQuadSplitValue((event.clientX - rect.left) / rect.width);
}
if (resizeMode !== "vertical") {
nextViewportQuadSplit.y = clampViewportQuadSplitValue((event.clientY - rect.top) / rect.height);
}
store.setViewportQuadSplit(nextViewportQuadSplit);
}
setViewportQuadResizeMode(resizeMode);
};
const beginCreation = (toolPreview: CreationViewportToolPreview, status: string) => {
blurActiveTextEntry();
closeAddMenu();
@@ -4035,6 +4169,8 @@ export function App({ store, initialStatusMessage }: AppProps) {
}
];
const viewportPanelsStyle = layoutMode === "quad" ? createViewportQuadPanelsStyle(editorState.viewportQuadSplit) : undefined;
if (editorState.toolMode === "play" && runtimeScene !== null) {
return (
<div className="app-shell app-shell--play">