Add transform session handling to ViewportCanvas and ViewportPanel

This commit is contained in:
2026-04-03 02:14:19 +02:00
parent 73a868ee33
commit 9abbe7caf5
3 changed files with 231 additions and 11 deletions

View File

@@ -6,6 +6,7 @@ import type { ProjectAssetRecord } from "../assets/project-assets";
import type { EditorSelection } from "../core/selection"; import type { EditorSelection } from "../core/selection";
import type { ToolMode } from "../core/tool-mode"; import type { ToolMode } from "../core/tool-mode";
import type { Vec3 } from "../core/vector"; import type { Vec3 } from "../core/vector";
import type { ActiveTransformSession, TransformSessionState } from "../core/transform-session";
import type { SceneDocument } from "../document/scene-document"; import type { SceneDocument } from "../document/scene-document";
import type { WorldSettings } from "../document/world-settings"; import type { WorldSettings } from "../document/world-settings";
import { createWorldBackgroundStyle } from "../shared-ui/world-background-style"; import { createWorldBackgroundStyle } from "../shared-ui/world-background-style";
@@ -34,6 +35,7 @@ interface ViewportCanvasProps {
selection: EditorSelection; selection: EditorSelection;
toolMode: ToolMode; toolMode: ToolMode;
toolPreview: ViewportToolPreview; toolPreview: ViewportToolPreview;
transformSession: TransformSessionState;
cameraState: ViewportPanelCameraState; cameraState: ViewportPanelCameraState;
viewMode: ViewportViewMode; viewMode: ViewportViewMode;
displayMode: ViewportDisplayMode; displayMode: ViewportDisplayMode;
@@ -45,6 +47,9 @@ interface ViewportCanvasProps {
onCommitCreation(toolPreview: CreationViewportToolPreview): boolean; onCommitCreation(toolPreview: CreationViewportToolPreview): boolean;
onCameraStateChange(cameraState: ViewportPanelCameraState): void; onCameraStateChange(cameraState: ViewportPanelCameraState): void;
onToolPreviewChange(toolPreview: ViewportToolPreview): void; onToolPreviewChange(toolPreview: ViewportToolPreview): void;
onTransformSessionChange(transformSession: TransformSessionState): void;
onTransformCommit(transformSession: ActiveTransformSession): void;
onTransformCancel(): void;
} }
export function ViewportCanvas({ export function ViewportCanvas({
@@ -57,6 +62,7 @@ export function ViewportCanvas({
selection, selection,
toolMode, toolMode,
toolPreview, toolPreview,
transformSession,
cameraState, cameraState,
viewMode, viewMode,
displayMode, displayMode,
@@ -67,7 +73,10 @@ export function ViewportCanvas({
onSelectionChange, onSelectionChange,
onCommitCreation, onCommitCreation,
onCameraStateChange, onCameraStateChange,
onToolPreviewChange onToolPreviewChange,
onTransformSessionChange,
onTransformCommit,
onTransformCancel
}: ViewportCanvasProps) { }: ViewportCanvasProps) {
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
const hostRef = useRef<ViewportHost | null>(null); const hostRef = useRef<ViewportHost | null>(null);
@@ -94,6 +103,7 @@ export function ViewportCanvas({
try { try {
const viewportHost = new ViewportHost(); const viewportHost = new ViewportHost();
hostRef.current = viewportHost; hostRef.current = viewportHost;
viewportHost.setPanelId(panelId);
viewportHost.mount(container); viewportHost.mount(container);
setViewportMessage(null); setViewportMessage(null);
@@ -108,6 +118,10 @@ export function ViewportCanvas({
} }
}, []); }, []);
useEffect(() => {
hostRef.current?.setPanelId(panelId);
}, [panelId]);
useEffect(() => { useEffect(() => {
hostRef.current?.updateWorld(world); hostRef.current?.updateWorld(world);
}, [world]); }, [world]);
@@ -157,6 +171,18 @@ export function ViewportCanvas({
hostRef.current?.setCreationCommitHandler(onCommitCreation); hostRef.current?.setCreationCommitHandler(onCommitCreation);
}, [onCommitCreation]); }, [onCommitCreation]);
useEffect(() => {
hostRef.current?.setTransformSessionChangeHandler(onTransformSessionChange);
}, [onTransformSessionChange]);
useEffect(() => {
hostRef.current?.setTransformCommitHandler(onTransformCommit);
}, [onTransformCommit]);
useEffect(() => {
hostRef.current?.setTransformCancelHandler(onTransformCancel);
}, [onTransformCancel]);
useEffect(() => { useEffect(() => {
hostRef.current?.setToolMode(toolMode); hostRef.current?.setToolMode(toolMode);
}, [toolMode]); }, [toolMode]);
@@ -165,6 +191,10 @@ export function ViewportCanvas({
hostRef.current?.setCreationPreview(toolMode === "create" && toolPreview.kind === "create" ? toolPreview : null); hostRef.current?.setCreationPreview(toolMode === "create" && toolPreview.kind === "create" ? toolPreview : null);
}, [toolMode, toolPreview]); }, [toolMode, toolPreview]);
useEffect(() => {
hostRef.current?.setTransformSession(transformSession);
}, [transformSession]);
useEffect(() => { useEffect(() => {
if (focusRequestId === 0) { if (focusRequestId === 0) {
return; return;
@@ -174,8 +204,9 @@ export function ViewportCanvas({
}, [focusRequestId, focusSelection, sceneDocument]); }, [focusRequestId, focusSelection, sceneDocument]);
const previewVisible = toolMode === "create" && toolPreview.kind === "create" && toolPreview.center !== null; const previewVisible = toolMode === "create" && toolPreview.kind === "create" && toolPreview.center !== null;
const transformPreviewVisible = transformSession.kind === "active";
const showViewModeOverlay = layoutMode === "quad"; const showViewModeOverlay = layoutMode === "quad";
const showOverlay = showViewModeOverlay || previewVisible; const showOverlay = showViewModeOverlay || previewVisible || transformPreviewVisible;
return ( return (
<div <div
@@ -205,6 +236,13 @@ export function ViewportCanvas({
Preview: {(toolPreview.center as Vec3).x}, {(toolPreview.center as Vec3).y}, {(toolPreview.center as Vec3).z} Preview: {(toolPreview.center as Vec3).x}, {(toolPreview.center as Vec3).y}, {(toolPreview.center as Vec3).z}
</div> </div>
)} )}
{!transformPreviewVisible ? null : (
<div className="viewport-canvas__overlay-preview" data-testid={`viewport-transform-preview-${panelId}`}>
{transformSession.kind !== "active"
? null
: `${transformSession.operation}${transformSession.axisConstraint === null ? "" : ` · ${transformSession.axisConstraint.toUpperCase()}`}`}
</div>
)}
</div> </div>
)} )}

View File

@@ -16,6 +16,7 @@ import type { LoadedModelAsset } from "../assets/gltf-model-import";
import type { LoadedImageAsset } from "../assets/image-assets"; import type { LoadedImageAsset } from "../assets/image-assets";
import type { ProjectAssetRecord } from "../assets/project-assets"; import type { ProjectAssetRecord } from "../assets/project-assets";
import type { EditorSelection } from "../core/selection"; import type { EditorSelection } from "../core/selection";
import type { ActiveTransformSession, TransformSessionState } from "../core/transform-session";
import type { ToolMode } from "../core/tool-mode"; import type { ToolMode } from "../core/tool-mode";
import type { SceneDocument } from "../document/scene-document"; import type { SceneDocument } from "../document/scene-document";
import type { WorldSettings } from "../document/world-settings"; import type { WorldSettings } from "../document/world-settings";
@@ -35,6 +36,7 @@ interface ViewportPanelProps {
selection: EditorSelection; selection: EditorSelection;
toolMode: ToolMode; toolMode: ToolMode;
toolPreview: ViewportToolPreview; toolPreview: ViewportToolPreview;
transformSession: TransformSessionState;
cameraState: ViewportPanelCameraState; cameraState: ViewportPanelCameraState;
focusRequestId: number; focusRequestId: number;
focusSelection: EditorSelection; focusSelection: EditorSelection;
@@ -44,6 +46,9 @@ interface ViewportPanelProps {
onCommitCreation(toolPreview: CreationViewportToolPreview): boolean; onCommitCreation(toolPreview: CreationViewportToolPreview): boolean;
onCameraStateChange(cameraState: ViewportPanelCameraState): void; onCameraStateChange(cameraState: ViewportPanelCameraState): void;
onToolPreviewChange(toolPreview: ViewportToolPreview): void; onToolPreviewChange(toolPreview: ViewportToolPreview): void;
onTransformSessionChange(transformSession: TransformSessionState): void;
onTransformCommit(transformSession: ActiveTransformSession): void;
onTransformCancel(): void;
onSelectionChange(selection: EditorSelection): void; onSelectionChange(selection: EditorSelection): void;
} }
@@ -62,6 +67,7 @@ export function ViewportPanel({
selection, selection,
toolMode, toolMode,
toolPreview, toolPreview,
transformSession,
cameraState, cameraState,
focusRequestId, focusRequestId,
focusSelection, focusSelection,
@@ -71,6 +77,9 @@ export function ViewportPanel({
onCommitCreation, onCommitCreation,
onCameraStateChange, onCameraStateChange,
onToolPreviewChange, onToolPreviewChange,
onTransformSessionChange,
onTransformCommit,
onTransformCancel,
onSelectionChange onSelectionChange
}: ViewportPanelProps) { }: ViewportPanelProps) {
const shouldShow = layoutMode === "quad" || isActive; const shouldShow = layoutMode === "quad" || isActive;
@@ -131,6 +140,7 @@ export function ViewportPanel({
selection={selection} selection={selection}
toolMode={toolMode} toolMode={toolMode}
toolPreview={toolPreview} toolPreview={toolPreview}
transformSession={transformSession}
cameraState={cameraState} cameraState={cameraState}
viewMode={panelState.viewMode} viewMode={panelState.viewMode}
displayMode={panelState.displayMode} displayMode={panelState.displayMode}
@@ -142,6 +152,9 @@ export function ViewportPanel({
onCommitCreation={onCommitCreation} onCommitCreation={onCommitCreation}
onCameraStateChange={onCameraStateChange} onCameraStateChange={onCameraStateChange}
onToolPreviewChange={onToolPreviewChange} onToolPreviewChange={onToolPreviewChange}
onTransformSessionChange={onTransformSessionChange}
onTransformCommit={onTransformCommit}
onTransformCancel={onTransformCancel}
/> />
</section> </section>
); );

View File

@@ -1034,13 +1034,17 @@ export class ViewportHost {
this.transformGizmoGroup.add(this.createTranslateHandle("y", session.axisConstraint === "y")); this.transformGizmoGroup.add(this.createTranslateHandle("y", session.axisConstraint === "y"));
this.transformGizmoGroup.add(this.createTranslateHandle("z", session.axisConstraint === "z")); this.transformGizmoGroup.add(this.createTranslateHandle("z", session.axisConstraint === "z"));
} else if (session.operation === "rotate") { } else if (session.operation === "rotate") {
this.transformGizmoGroup.add(this.createRotateHandle("x", effectiveRotationAxis === "x")); for (const axis of ["x", "y", "z"] as const) {
this.transformGizmoGroup.add(this.createRotateHandle("y", effectiveRotationAxis === "y")); if (!supportsTransformAxisConstraint(session, axis)) {
this.transformGizmoGroup.add(this.createRotateHandle("z", effectiveRotationAxis === "z")); continue;
}
this.transformGizmoGroup.add(this.createRotateHandle(axis, effectiveRotationAxis === axis));
}
} else if (session.operation === "scale" && session.target.kind === "modelInstance") { } else if (session.operation === "scale" && session.target.kind === "modelInstance") {
this.transformGizmoGroup.add(this.createScaleHandle("x", session.axisConstraint === "x")); for (const axis of ["x", "y", "z"] as const) {
this.transformGizmoGroup.add(this.createScaleHandle("y", session.axisConstraint === "y")); this.transformGizmoGroup.add(this.createScaleHandle(axis, session.axisConstraint === axis));
this.transformGizmoGroup.add(this.createScaleHandle("z", session.axisConstraint === "z")); }
this.transformGizmoGroup.add(this.createUniformScaleHandle(session.axisConstraint === null)); this.transformGizmoGroup.add(this.createUniformScaleHandle(session.axisConstraint === null));
} }
@@ -2191,7 +2195,38 @@ export class ViewportHost {
this.advancedRenderingComposer?.setSize(width, height); this.advancedRenderingComposer?.setSize(width, height);
} }
private pickTransformHandle(event: PointerEvent): { axisConstraint: TransformAxis | null } | null {
if (this.currentTransformSession.kind !== "active" || !this.transformGizmoGroup.visible) {
return null;
}
if (!this.setPointerFromClientPosition(event.clientX, event.clientY)) {
return null;
}
this.raycaster.setFromCamera(this.pointer, this.getActiveCamera());
const hits = this.raycaster.intersectObjects(this.transformGizmoGroup.children, true);
for (const hit of hits) {
const axisConstraint = hit.object.userData.transformAxisConstraint;
if (axisConstraint === null || axisConstraint === "x" || axisConstraint === "y" || axisConstraint === "z") {
return {
axisConstraint
};
}
}
return null;
}
private handlePointerDown = (event: PointerEvent) => { private handlePointerDown = (event: PointerEvent) => {
this.lastCanvasPointerPosition = {
x: event.clientX,
y: event.clientY
};
if (event.button === 1) { if (event.button === 1) {
event.preventDefault(); event.preventDefault();
this.activeCameraDragPointerId = event.pointerId; this.activeCameraDragPointerId = event.pointerId;
@@ -2207,6 +2242,66 @@ export class ViewportHost {
return; return;
} }
if (this.currentTransformSession.kind === "active") {
if (this.currentTransformSession.sourcePanelId !== this.panelId) {
return;
}
const transformHandle = this.pickTransformHandle(event);
if (transformHandle !== null) {
event.preventDefault();
if (
transformHandle.axisConstraint !== null &&
!supportsTransformAxisConstraint(this.currentTransformSession, transformHandle.axisConstraint)
) {
return;
}
const nextSession = this.buildTransformPreviewFromPointer(
createTransformSession({
source: "gizmo",
sourcePanelId: this.panelId,
operation: this.currentTransformSession.operation,
axisConstraint: transformHandle.axisConstraint,
target: this.currentTransformSession.target
}),
{
x: event.clientX,
y: event.clientY
},
{
x: event.clientX,
y: event.clientY
},
transformHandle.axisConstraint
);
this.currentTransformSession = nextSession;
this.applyTransformPreview();
this.syncTransformGizmo();
this.transformSessionChangeHandler?.(nextSession);
this.activeTransformDrag = {
pointerId: event.pointerId,
sessionId: nextSession.id,
axisConstraint: transformHandle.axisConstraint,
initialClientPosition: {
x: event.clientX,
y: event.clientY
}
};
this.renderer.domElement.setPointerCapture(event.pointerId);
return;
}
if (this.currentTransformSession.source !== "gizmo" || this.currentTransformSession.sourcePanelId === this.panelId) {
event.preventDefault();
this.transformCommitHandler?.(this.currentTransformSession);
return;
}
}
if (this.toolMode === "create" && this.creationPreview !== null) { if (this.toolMode === "create" && this.creationPreview !== null) {
const previewCenter = this.getCreationPreviewCenter(event, this.creationPreview.target); const previewCenter = this.getCreationPreviewCenter(event, this.creationPreview.target);
const nextCreationPreview = { const nextCreationPreview = {
@@ -2281,8 +2376,7 @@ export class ViewportHost {
} else if (typeof brushId === "string") { } else if (typeof brushId === "string") {
const faceMaterialIndex = hit.face?.materialIndex; const faceMaterialIndex = hit.face?.materialIndex;
const faceId = typeof faceMaterialIndex === "number" ? BOX_FACE_IDS[faceMaterialIndex] ?? null : null; const faceId = typeof faceMaterialIndex === "number" ? BOX_FACE_IDS[faceMaterialIndex] ?? null : null;
// In face-edit mode each face is a distinct candidate; in brush mode collapse to brush. key = event.altKey && faceId !== null ? `brushFace:${brushId}:${faceId}` : `brush:${brushId}`;
key = faceId !== null ? `brushFace:${brushId}:${faceId}` : `brush:${brushId}`;
} else { } else {
continue; continue;
} }
@@ -2343,7 +2437,7 @@ export class ViewportHost {
return; return;
} }
if (faceId !== null) { if (event.altKey && faceId !== null) {
this.brushSelectionChangeHandler?.({ kind: "brushFace", brushId, faceId }); this.brushSelectionChangeHandler?.({ kind: "brushFace", brushId, faceId });
return; return;
} }
@@ -2352,6 +2446,11 @@ export class ViewportHost {
}; };
private handlePointerMove = (event: PointerEvent) => { private handlePointerMove = (event: PointerEvent) => {
this.lastCanvasPointerPosition = {
x: event.clientX,
y: event.clientY
};
if (this.activeCameraDragPointerId === event.pointerId && this.lastCameraDragClientPosition !== null) { if (this.activeCameraDragPointerId === event.pointerId && this.lastCameraDragClientPosition !== null) {
const deltaX = event.clientX - this.lastCameraDragClientPosition.x; const deltaX = event.clientX - this.lastCameraDragClientPosition.x;
const deltaY = event.clientY - this.lastCameraDragClientPosition.y; const deltaY = event.clientY - this.lastCameraDragClientPosition.y;
@@ -2370,6 +2469,56 @@ export class ViewportHost {
return; return;
} }
if (
this.activeTransformDrag !== null &&
this.activeTransformDrag.pointerId === event.pointerId &&
this.currentTransformSession.kind === "active" &&
this.currentTransformSession.id === this.activeTransformDrag.sessionId
) {
const nextSession = this.buildTransformPreviewFromPointer(
this.currentTransformSession,
this.activeTransformDrag.initialClientPosition,
{
x: event.clientX,
y: event.clientY
},
this.activeTransformDrag.axisConstraint
);
this.currentTransformSession = nextSession;
this.applyTransformPreview();
this.syncTransformGizmo();
this.transformSessionChangeHandler?.(nextSession);
return;
}
if (
this.currentTransformSession.kind === "active" &&
this.currentTransformSession.sourcePanelId === this.panelId &&
this.currentTransformSession.source !== "gizmo" &&
this.keyboardTransformPointerOrigin !== null &&
this.keyboardTransformPointerOrigin.sessionId === this.currentTransformSession.id
) {
const nextSession = this.buildTransformPreviewFromPointer(
this.currentTransformSession,
{
x: this.keyboardTransformPointerOrigin.clientX,
y: this.keyboardTransformPointerOrigin.clientY
},
{
x: event.clientX,
y: event.clientY
},
this.currentTransformSession.axisConstraint
);
this.currentTransformSession = nextSession;
this.applyTransformPreview();
this.syncTransformGizmo();
this.transformSessionChangeHandler?.(nextSession);
return;
}
if (this.toolMode !== "create" || this.creationPreview === null) { if (this.toolMode !== "create" || this.creationPreview === null) {
return; return;
} }
@@ -2385,6 +2534,25 @@ export class ViewportHost {
}; };
private handlePointerUp = (event: PointerEvent) => { private handlePointerUp = (event: PointerEvent) => {
if (this.activeTransformDrag !== null && this.activeTransformDrag.pointerId === event.pointerId) {
if (this.renderer.domElement.hasPointerCapture(event.pointerId)) {
this.renderer.domElement.releasePointerCapture(event.pointerId);
}
const completedSession = this.currentTransformSession.kind === "active" ? this.currentTransformSession : null;
this.activeTransformDrag = null;
if (completedSession !== null) {
if (event.type === "pointercancel") {
this.transformCancelHandler?.();
} else {
this.transformCommitHandler?.(completedSession);
}
}
return;
}
if (this.activeCameraDragPointerId !== event.pointerId) { if (this.activeCameraDragPointerId !== event.pointerId) {
return; return;
} }
@@ -2799,6 +2967,7 @@ export class ViewportHost {
private render = () => { private render = () => {
this.animationFrame = window.requestAnimationFrame(this.render); this.animationFrame = window.requestAnimationFrame(this.render);
this.updateTransformGizmoPose();
if (this.advancedRenderingComposer !== null) { if (this.advancedRenderingComposer !== null) {
this.advancedRenderingComposer.render(); this.advancedRenderingComposer.render();