From 9abbe7caf5eb6e2f711c8ff3b4d660ba3be21fb1 Mon Sep 17 00:00:00 2001 From: Victor Giers Date: Fri, 3 Apr 2026 02:14:19 +0200 Subject: [PATCH] Add transform session handling to ViewportCanvas and ViewportPanel --- src/viewport-three/ViewportCanvas.tsx | 42 +++++- src/viewport-three/ViewportPanel.tsx | 13 ++ src/viewport-three/viewport-host.ts | 187 ++++++++++++++++++++++++-- 3 files changed, 231 insertions(+), 11 deletions(-) diff --git a/src/viewport-three/ViewportCanvas.tsx b/src/viewport-three/ViewportCanvas.tsx index 109f9878..cecadc78 100644 --- a/src/viewport-three/ViewportCanvas.tsx +++ b/src/viewport-three/ViewportCanvas.tsx @@ -6,6 +6,7 @@ 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 type { ActiveTransformSession, TransformSessionState } from "../core/transform-session"; import type { SceneDocument } from "../document/scene-document"; import type { WorldSettings } from "../document/world-settings"; import { createWorldBackgroundStyle } from "../shared-ui/world-background-style"; @@ -34,6 +35,7 @@ interface ViewportCanvasProps { selection: EditorSelection; toolMode: ToolMode; toolPreview: ViewportToolPreview; + transformSession: TransformSessionState; cameraState: ViewportPanelCameraState; viewMode: ViewportViewMode; displayMode: ViewportDisplayMode; @@ -45,6 +47,9 @@ interface ViewportCanvasProps { onCommitCreation(toolPreview: CreationViewportToolPreview): boolean; onCameraStateChange(cameraState: ViewportPanelCameraState): void; onToolPreviewChange(toolPreview: ViewportToolPreview): void; + onTransformSessionChange(transformSession: TransformSessionState): void; + onTransformCommit(transformSession: ActiveTransformSession): void; + onTransformCancel(): void; } export function ViewportCanvas({ @@ -57,6 +62,7 @@ export function ViewportCanvas({ selection, toolMode, toolPreview, + transformSession, cameraState, viewMode, displayMode, @@ -67,7 +73,10 @@ export function ViewportCanvas({ onSelectionChange, onCommitCreation, onCameraStateChange, - onToolPreviewChange + onToolPreviewChange, + onTransformSessionChange, + onTransformCommit, + onTransformCancel }: ViewportCanvasProps) { const containerRef = useRef(null); const hostRef = useRef(null); @@ -94,6 +103,7 @@ export function ViewportCanvas({ try { const viewportHost = new ViewportHost(); hostRef.current = viewportHost; + viewportHost.setPanelId(panelId); viewportHost.mount(container); setViewportMessage(null); @@ -108,6 +118,10 @@ export function ViewportCanvas({ } }, []); + useEffect(() => { + hostRef.current?.setPanelId(panelId); + }, [panelId]); + useEffect(() => { hostRef.current?.updateWorld(world); }, [world]); @@ -157,6 +171,18 @@ export function ViewportCanvas({ hostRef.current?.setCreationCommitHandler(onCommitCreation); }, [onCommitCreation]); + useEffect(() => { + hostRef.current?.setTransformSessionChangeHandler(onTransformSessionChange); + }, [onTransformSessionChange]); + + useEffect(() => { + hostRef.current?.setTransformCommitHandler(onTransformCommit); + }, [onTransformCommit]); + + useEffect(() => { + hostRef.current?.setTransformCancelHandler(onTransformCancel); + }, [onTransformCancel]); + useEffect(() => { hostRef.current?.setToolMode(toolMode); }, [toolMode]); @@ -165,6 +191,10 @@ export function ViewportCanvas({ hostRef.current?.setCreationPreview(toolMode === "create" && toolPreview.kind === "create" ? toolPreview : null); }, [toolMode, toolPreview]); + useEffect(() => { + hostRef.current?.setTransformSession(transformSession); + }, [transformSession]); + useEffect(() => { if (focusRequestId === 0) { return; @@ -174,8 +204,9 @@ export function ViewportCanvas({ }, [focusRequestId, focusSelection, sceneDocument]); const previewVisible = toolMode === "create" && toolPreview.kind === "create" && toolPreview.center !== null; + const transformPreviewVisible = transformSession.kind === "active"; const showViewModeOverlay = layoutMode === "quad"; - const showOverlay = showViewModeOverlay || previewVisible; + const showOverlay = showViewModeOverlay || previewVisible || transformPreviewVisible; return (
)} + {!transformPreviewVisible ? null : ( +
+ {transformSession.kind !== "active" + ? null + : `${transformSession.operation}${transformSession.axisConstraint === null ? "" : ` ยท ${transformSession.axisConstraint.toUpperCase()}`}`} +
+ )}
)} diff --git a/src/viewport-three/ViewportPanel.tsx b/src/viewport-three/ViewportPanel.tsx index d88adc47..022f69ff 100644 --- a/src/viewport-three/ViewportPanel.tsx +++ b/src/viewport-three/ViewportPanel.tsx @@ -16,6 +16,7 @@ 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 { ActiveTransformSession, TransformSessionState } from "../core/transform-session"; import type { ToolMode } from "../core/tool-mode"; import type { SceneDocument } from "../document/scene-document"; import type { WorldSettings } from "../document/world-settings"; @@ -35,6 +36,7 @@ interface ViewportPanelProps { selection: EditorSelection; toolMode: ToolMode; toolPreview: ViewportToolPreview; + transformSession: TransformSessionState; cameraState: ViewportPanelCameraState; focusRequestId: number; focusSelection: EditorSelection; @@ -44,6 +46,9 @@ interface ViewportPanelProps { onCommitCreation(toolPreview: CreationViewportToolPreview): boolean; onCameraStateChange(cameraState: ViewportPanelCameraState): void; onToolPreviewChange(toolPreview: ViewportToolPreview): void; + onTransformSessionChange(transformSession: TransformSessionState): void; + onTransformCommit(transformSession: ActiveTransformSession): void; + onTransformCancel(): void; onSelectionChange(selection: EditorSelection): void; } @@ -62,6 +67,7 @@ export function ViewportPanel({ selection, toolMode, toolPreview, + transformSession, cameraState, focusRequestId, focusSelection, @@ -71,6 +77,9 @@ export function ViewportPanel({ onCommitCreation, onCameraStateChange, onToolPreviewChange, + onTransformSessionChange, + onTransformCommit, + onTransformCancel, onSelectionChange }: ViewportPanelProps) { const shouldShow = layoutMode === "quad" || isActive; @@ -131,6 +140,7 @@ export function ViewportPanel({ selection={selection} toolMode={toolMode} toolPreview={toolPreview} + transformSession={transformSession} cameraState={cameraState} viewMode={panelState.viewMode} displayMode={panelState.displayMode} @@ -142,6 +152,9 @@ export function ViewportPanel({ onCommitCreation={onCommitCreation} onCameraStateChange={onCameraStateChange} onToolPreviewChange={onToolPreviewChange} + onTransformSessionChange={onTransformSessionChange} + onTransformCommit={onTransformCommit} + onTransformCancel={onTransformCancel} /> ); diff --git a/src/viewport-three/viewport-host.ts b/src/viewport-three/viewport-host.ts index 8d7bb502..989a029a 100644 --- a/src/viewport-three/viewport-host.ts +++ b/src/viewport-three/viewport-host.ts @@ -1034,13 +1034,17 @@ export class ViewportHost { this.transformGizmoGroup.add(this.createTranslateHandle("y", session.axisConstraint === "y")); this.transformGizmoGroup.add(this.createTranslateHandle("z", session.axisConstraint === "z")); } else if (session.operation === "rotate") { - this.transformGizmoGroup.add(this.createRotateHandle("x", effectiveRotationAxis === "x")); - this.transformGizmoGroup.add(this.createRotateHandle("y", effectiveRotationAxis === "y")); - this.transformGizmoGroup.add(this.createRotateHandle("z", effectiveRotationAxis === "z")); + for (const axis of ["x", "y", "z"] as const) { + if (!supportsTransformAxisConstraint(session, axis)) { + continue; + } + + this.transformGizmoGroup.add(this.createRotateHandle(axis, effectiveRotationAxis === axis)); + } } else if (session.operation === "scale" && session.target.kind === "modelInstance") { - this.transformGizmoGroup.add(this.createScaleHandle("x", session.axisConstraint === "x")); - this.transformGizmoGroup.add(this.createScaleHandle("y", session.axisConstraint === "y")); - this.transformGizmoGroup.add(this.createScaleHandle("z", session.axisConstraint === "z")); + for (const axis of ["x", "y", "z"] as const) { + this.transformGizmoGroup.add(this.createScaleHandle(axis, session.axisConstraint === axis)); + } this.transformGizmoGroup.add(this.createUniformScaleHandle(session.axisConstraint === null)); } @@ -2191,7 +2195,38 @@ export class ViewportHost { 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) => { + this.lastCanvasPointerPosition = { + x: event.clientX, + y: event.clientY + }; + if (event.button === 1) { event.preventDefault(); this.activeCameraDragPointerId = event.pointerId; @@ -2207,6 +2242,66 @@ export class ViewportHost { 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) { const previewCenter = this.getCreationPreviewCenter(event, this.creationPreview.target); const nextCreationPreview = { @@ -2281,8 +2376,7 @@ export class ViewportHost { } else if (typeof brushId === "string") { const faceMaterialIndex = hit.face?.materialIndex; 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 = faceId !== null ? `brushFace:${brushId}:${faceId}` : `brush:${brushId}`; + key = event.altKey && faceId !== null ? `brushFace:${brushId}:${faceId}` : `brush:${brushId}`; } else { continue; } @@ -2343,7 +2437,7 @@ export class ViewportHost { return; } - if (faceId !== null) { + if (event.altKey && faceId !== null) { this.brushSelectionChangeHandler?.({ kind: "brushFace", brushId, faceId }); return; } @@ -2352,6 +2446,11 @@ export class ViewportHost { }; private handlePointerMove = (event: PointerEvent) => { + this.lastCanvasPointerPosition = { + x: event.clientX, + y: event.clientY + }; + if (this.activeCameraDragPointerId === event.pointerId && this.lastCameraDragClientPosition !== null) { const deltaX = event.clientX - this.lastCameraDragClientPosition.x; const deltaY = event.clientY - this.lastCameraDragClientPosition.y; @@ -2370,6 +2469,56 @@ export class ViewportHost { 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) { return; } @@ -2385,6 +2534,25 @@ export class ViewportHost { }; 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) { return; } @@ -2799,6 +2967,7 @@ export class ViewportHost { private render = () => { this.animationFrame = window.requestAnimationFrame(this.render); + this.updateTransformGizmoPose(); if (this.advancedRenderingComposer !== null) { this.advancedRenderingComposer.render();