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 { 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<HTMLDivElement | null>(null);
const hostRef = useRef<ViewportHost | null>(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 (
<div
@@ -205,6 +236,13 @@ export function ViewportCanvas({
Preview: {(toolPreview.center as Vec3).x}, {(toolPreview.center as Vec3).y}, {(toolPreview.center as Vec3).z}
</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>
)}

View File

@@ -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}
/>
</section>
);

View File

@@ -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();