diff --git a/src/app/App.tsx b/src/app/App.tsx index 98c483ad..54202ad8 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -47,9 +47,12 @@ import { import { createTransformSession, doesTransformSessionChangeTarget, + getTransformAxisLabel, + getTransformAxisSpaceLabel, getTransformOperationLabel, getTransformTargetLabel, resolveTransformTarget, + supportsLocalTransformAxisConstraint, supportsTransformAxisConstraint, supportsTransformOperation, type ActiveTransformSession, diff --git a/src/viewport-three/viewport-host.ts b/src/viewport-three/viewport-host.ts index 388183b6..02535fbc 100644 --- a/src/viewport-three/viewport-host.ts +++ b/src/viewport-three/viewport-host.ts @@ -384,6 +384,7 @@ export class ViewportHost { pointerId: number; sessionId: string; axisConstraint: TransformAxis | null; + axisConstraintSpace: TransformAxisSpace; initialClientPosition: { x: number; y: number; @@ -411,9 +412,19 @@ export class ViewportHost { this.gridHelpers.xy.rotation.x = Math.PI * 0.5; this.gridHelpers.yz.rotation.z = Math.PI * 0.5; - this.gridHelpers.xz.visible = true; - this.gridHelpers.xy.visible = false; - this.gridHelpers.yz.visible = false; + + for (const gridHelper of Object.values(this.gridHelpers)) { + const gridMaterial = gridHelper.material as LineBasicMaterial; + const centerLineMaterial = gridHelper.children[0]?.material as LineBasicMaterial | undefined; + + gridMaterial.transparent = true; + gridMaterial.opacity = 0.48; + + if (centerLineMaterial !== undefined) { + centerLineMaterial.transparent = true; + centerLineMaterial.opacity = 0.8; + } + } this.scene.add(this.gridHelpers.xz); this.scene.add(this.gridHelpers.xy); @@ -593,6 +604,15 @@ export class ViewportHost { this.applyTransformPreview(); } + setGridVisible(visible: boolean) { + if (this.viewportGridVisible === visible) { + return; + } + + this.viewportGridVisible = visible; + this.updateGridPresentation(); + } + setWhiteboxSelectionMode(mode: WhiteboxSelectionMode) { if (this.whiteboxSelectionMode === mode) { return; @@ -609,6 +629,7 @@ export class ViewportHost { } setTransformSession(transformSession: TransformSessionState) { + const previousTransformSession = this.currentTransformSession; this.currentTransformSession = cloneTransformSession(transformSession); if (this.currentTransformSession.kind === "none") { @@ -627,6 +648,30 @@ export class ViewportHost { }; } + if ( + previousTransformSession.kind === "active" && + this.currentTransformSession.kind === "active" && + previousTransformSession.id === this.currentTransformSession.id && + (previousTransformSession.axisConstraint !== this.currentTransformSession.axisConstraint || + previousTransformSession.axisConstraintSpace !== this.currentTransformSession.axisConstraintSpace) && + this.currentTransformSession.sourcePanelId === this.panelId && + this.currentTransformSession.source !== "gizmo" && + this.keyboardTransformPointerOrigin !== null && + this.keyboardTransformPointerOrigin.sessionId === this.currentTransformSession.id && + this.lastCanvasPointerPosition !== null + ) { + this.currentTransformSession = this.buildTransformPreviewFromPointer( + this.currentTransformSession, + { + x: this.keyboardTransformPointerOrigin.clientX, + y: this.keyboardTransformPointerOrigin.clientY + }, + this.lastCanvasPointerPosition, + this.currentTransformSession.axisConstraint, + this.currentTransformSession.axisConstraintSpace + ); + } + this.applyTransformPreview(); this.syncTransformGizmo(); } @@ -850,12 +895,9 @@ export class ViewportHost { } private applyViewModePose() { + this.updateGridPresentation(); + const definition = getViewportViewModeDefinition(this.viewMode); - - this.gridHelpers.xz.visible = definition.gridPlane === "xz"; - this.gridHelpers.xy.visible = definition.gridPlane === "xy"; - this.gridHelpers.yz.visible = definition.gridPlane === "yz"; - if (definition.cameraType === "perspective") { this.applyPerspectiveCameraPose(); return; @@ -865,6 +907,24 @@ export class ViewportHost { this.applyOrthographicCameraPose(); } + private updateGridPresentation() { + const definition = getViewportViewModeDefinition(this.viewMode); + const visibleGridPlane = this.viewportGridVisible ? definition.gridPlane : null; + + this.gridHelpers.xz.visible = visibleGridPlane === "xz"; + this.gridHelpers.xy.visible = visibleGridPlane === "xy"; + this.gridHelpers.yz.visible = visibleGridPlane === "yz"; + this.updateGridPositioning(); + } + + private updateGridPositioning() { + const align = (value: number) => Math.round(value / DEFAULT_GRID_SIZE) * DEFAULT_GRID_SIZE; + + this.gridHelpers.xz.position.set(align(this.cameraTarget.x), 0, align(this.cameraTarget.z)); + this.gridHelpers.xy.position.set(align(this.cameraTarget.x), align(this.cameraTarget.y), 0); + this.gridHelpers.yz.position.set(0, align(this.cameraTarget.y), align(this.cameraTarget.z)); + } + private createWireframeDisplayMaterial(material: Material): MeshBasicMaterial { const source = material as Material & { color?: { getHex(): number }; @@ -1044,6 +1104,87 @@ export class ViewportHost { } } + private createRotationQuaternion(rotationDegrees: Vec3): Quaternion { + return new Quaternion().setFromEuler( + new Euler((rotationDegrees.x * Math.PI) / 180, (rotationDegrees.y * Math.PI) / 180, (rotationDegrees.z * Math.PI) / 180, "XYZ") + ); + } + + private getTransformTargetOrientation(session: ActiveTransformSession): Quaternion | null { + switch (session.target.kind) { + case "brush": + if (session.preview.kind !== "brush") { + return null; + } + + return this.createRotationQuaternion(session.preview.rotationDegrees); + case "modelInstance": + if (session.preview.kind !== "modelInstance") { + return null; + } + + return this.createRotationQuaternion(session.preview.rotationDegrees); + case "entity": + if (session.preview.kind !== "entity") { + return null; + } + + switch (session.preview.rotation.kind) { + case "yaw": + return this.createRotationQuaternion({ + x: 0, + y: session.preview.rotation.yawDegrees, + z: 0 + }); + case "direction": + return new Quaternion().setFromUnitVectors( + new Vector3(0, 1, 0), + new Vector3( + session.preview.rotation.direction.x, + session.preview.rotation.direction.y, + session.preview.rotation.direction.z + ).normalize() + ); + case "none": + return null; + } + case "brushFace": + case "brushEdge": + case "brushVertex": + return null; + } + } + + private getConstraintAxisWorldVector( + session: ActiveTransformSession, + axis: TransformAxis, + axisSpace: TransformAxisSpace + ): Vector3 { + const worldAxis = this.axisVector(axis); + + if (axisSpace !== "local") { + return worldAxis; + } + + const orientation = this.getTransformTargetOrientation(session); + + if (orientation === null) { + return worldAxis; + } + + return worldAxis.applyQuaternion(orientation).normalize(); + } + + private getQuaternionEulerDegrees(quaternion: Quaternion): Vec3 { + const euler = new Euler().setFromQuaternion(quaternion, "XYZ"); + + return { + x: this.normalizeDegrees((euler.x * 180) / Math.PI), + y: this.normalizeDegrees((euler.y * 180) / Math.PI), + z: this.normalizeDegrees((euler.z * 180) / Math.PI) + }; + } + private normalizeDegrees(value: number): number { const normalized = value % 360; return normalized < 0 ? normalized + 360 : normalized; @@ -1413,6 +1554,19 @@ export class ViewportHost { const pivotVector = new Vector3(pivot.x, pivot.y, pivot.z); this.transformGizmoGroup.position.copy(pivotVector); + this.transformGizmoGroup.quaternion.identity(); + + if ( + session.axisConstraint !== null && + session.axisConstraintSpace === "local" && + supportsLocalTransformAxisConstraint(session, session.axisConstraint) + ) { + const orientation = this.getTransformTargetOrientation(session); + + if (orientation !== null) { + this.transformGizmoGroup.quaternion.copy(orientation); + } + } let scale = GIZMO_SCREEN_SIZE_ORTHOGRAPHIC / Math.max(this.orthographicCamera.zoom, 0.0001); @@ -1478,15 +1632,15 @@ export class ViewportHost { return ORTHOGRAPHIC_FRUSTUM_HEIGHT / this.orthographicCamera.zoom / height; } - private getAxisMovementDistance( - axis: TransformAxis, + private getMovementDistanceAlongWorldAxis( + axisVector: Vector3, pivot: Vec3, origin: { x: number; y: number }, current: { x: number; y: number } ): number { const pivotVector = new Vector3(pivot.x, pivot.y, pivot.z); const projectedStart = pivotVector.clone().project(this.getActiveCamera()); - const projectedEnd = pivotVector.clone().add(this.axisVector(axis)).project(this.getActiveCamera()); + const projectedEnd = pivotVector.clone().add(axisVector.clone().normalize()).project(this.getActiveCamera()); const screenDelta = new Vector2(projectedEnd.x - projectedStart.x, projectedEnd.y - projectedStart.y); const pointerDelta = new Vector2(current.x - origin.x, current.y - origin.y); @@ -1504,21 +1658,35 @@ export class ViewportHost { return -(current.y - origin.y) * this.getFallbackWorldUnitsPerPixel(pivot); } + private getAxisMovementDistance( + axis: TransformAxis, + pivot: Vec3, + origin: { x: number; y: number }, + current: { x: number; y: number }, + session?: ActiveTransformSession, + axisSpace: TransformAxisSpace = "world" + ): number { + const axisVector = session === undefined ? this.axisVector(axis) : this.getConstraintAxisWorldVector(session, axis, axisSpace); + return this.getMovementDistanceAlongWorldAxis(axisVector, pivot, origin, current); + } + private buildTransformPreviewFromPointer( session: ActiveTransformSession, origin: { x: number; y: number }, current: { x: number; y: number }, - axisConstraint: TransformAxis | null + axisConstraint: TransformAxis | null, + axisConstraintSpace: TransformAxisSpace ): ActiveTransformSession { const nextSession = cloneTransformSession(session) as ActiveTransformSession; nextSession.axisConstraint = axisConstraint; + nextSession.axisConstraintSpace = axisConstraint === null ? "world" : axisConstraintSpace; switch (session.operation) { case "translate": - nextSession.preview = this.buildTranslatedPreview(session, origin, current, axisConstraint); + nextSession.preview = this.buildTranslatedPreview(session, origin, current, axisConstraint, nextSession.axisConstraintSpace); return nextSession; case "rotate": - nextSession.preview = this.buildRotatedPreview(session, origin, current, axisConstraint); + nextSession.preview = this.buildRotatedPreview(session, origin, current, axisConstraint, nextSession.axisConstraintSpace); return nextSession; case "scale": nextSession.preview = this.buildScaledPreview(session, origin, current, axisConstraint); @@ -1530,7 +1698,8 @@ export class ViewportHost { session: ActiveTransformSession, origin: { x: number; y: number }, current: { x: number; y: number }, - axisConstraint: TransformAxis | null + axisConstraint: TransformAxis | null, + axisConstraintSpace: TransformAxisSpace ) { if (session.target.kind === "brushFace" || session.target.kind === "brushEdge" || session.target.kind === "brushVertex") { return this.buildComponentTranslatedBrushPreview(session, origin, current, axisConstraint); @@ -1575,6 +1744,17 @@ export class ViewportHost { break; } } + } else if (axisConstraintSpace === "local" && supportsLocalTransformAxisConstraint(session, axisConstraint)) { + const axisVector = this.getConstraintAxisWorldVector(session, axisConstraint, axisConstraintSpace); + const axisDelta = this.getMovementDistanceAlongWorldAxis(axisVector, initialPosition, origin, current); + const snappedAxisDelta = this.whiteboxSnapEnabled ? snapValueToGrid(axisDelta, this.whiteboxSnapStep) : axisDelta; + + this.transformAxisDelta.copy(axisVector).multiplyScalar(snappedAxisDelta); + nextPosition = { + x: initialPosition.x + this.transformAxisDelta.x, + y: initialPosition.y + this.transformAxisDelta.y, + z: initialPosition.z + this.transformAxisDelta.z + }; } else { const axisDelta = this.getAxisMovementDistance(axisConstraint, initialPosition, origin, current); nextPosition = this.setAxisComponent( @@ -1637,7 +1817,8 @@ export class ViewportHost { session: ActiveTransformSession, origin: { x: number; y: number }, current: { x: number; y: number }, - axisConstraint: TransformAxis | null + axisConstraint: TransformAxis | null, + axisConstraintSpace: TransformAxisSpace ) { if (session.target.kind === "brushFace" || session.target.kind === "brushEdge") { return this.buildComponentRotatedBrushPreview(session, origin, current, axisConstraint); @@ -1645,13 +1826,28 @@ export class ViewportHost { const effectiveAxis = axisConstraint ?? this.getEffectiveRotationAxis(session); const pointerDeltaDegrees = (current.x - origin.x - (current.y - origin.y)) * 0.5; + const pointerDeltaRadians = (pointerDeltaDegrees * Math.PI) / 180; if (session.target.kind === "brush") { - const nextRotationDegrees = { + let nextRotationDegrees = { ...session.target.initialRotationDegrees }; - nextRotationDegrees[effectiveAxis] = this.normalizeDegrees(nextRotationDegrees[effectiveAxis] + pointerDeltaDegrees); + if (axisConstraint !== null) { + const initialOrientation = this.createRotationQuaternion(session.target.initialRotationDegrees); + const deltaRotation = new Quaternion().setFromAxisAngle( + this.getConstraintAxisWorldVector(session, effectiveAxis, axisConstraintSpace), + pointerDeltaRadians + ); + + nextRotationDegrees = this.getQuaternionEulerDegrees( + axisConstraintSpace === "local" && supportsLocalTransformAxisConstraint(session, effectiveAxis) + ? initialOrientation.multiply(deltaRotation) + : deltaRotation.multiply(initialOrientation) + ); + } else { + nextRotationDegrees[effectiveAxis] = this.normalizeDegrees(nextRotationDegrees[effectiveAxis] + pointerDeltaDegrees); + } return { kind: "brush" as const, @@ -1667,11 +1863,25 @@ export class ViewportHost { } if (session.target.kind === "modelInstance") { - const nextRotationDegrees = { + let nextRotationDegrees = { ...session.target.initialRotationDegrees }; - nextRotationDegrees[effectiveAxis] = this.normalizeDegrees(nextRotationDegrees[effectiveAxis] + pointerDeltaDegrees); + if (axisConstraint !== null) { + const initialOrientation = this.createRotationQuaternion(session.target.initialRotationDegrees); + const deltaRotation = new Quaternion().setFromAxisAngle( + this.getConstraintAxisWorldVector(session, effectiveAxis, axisConstraintSpace), + pointerDeltaRadians + ); + + nextRotationDegrees = this.getQuaternionEulerDegrees( + axisConstraintSpace === "local" && supportsLocalTransformAxisConstraint(session, effectiveAxis) + ? initialOrientation.multiply(deltaRotation) + : deltaRotation.multiply(initialOrientation) + ); + } else { + nextRotationDegrees[effectiveAxis] = this.normalizeDegrees(nextRotationDegrees[effectiveAxis] + pointerDeltaDegrees); + } return { kind: "modelInstance" as const, @@ -1690,6 +1900,27 @@ export class ViewportHost { } if (session.target.initialRotation.kind === "yaw") { + if (axisConstraint !== null && axisConstraintSpace === "local" && supportsLocalTransformAxisConstraint(session, effectiveAxis)) { + const initialOrientation = this.createRotationQuaternion({ + x: 0, + y: session.target.initialRotation.yawDegrees, + z: 0 + }); + const deltaRotation = new Quaternion().setFromAxisAngle(this.axisVector("y"), pointerDeltaRadians); + const nextRotationDegrees = this.getQuaternionEulerDegrees(initialOrientation.multiply(deltaRotation)); + + return { + kind: "entity" as const, + position: { + ...session.target.initialPosition + }, + rotation: { + kind: "yaw" as const, + yawDegrees: normalizeYawDegrees(nextRotationDegrees.y) + } + }; + } + return { kind: "entity" as const, position: { @@ -1703,14 +1934,23 @@ export class ViewportHost { } if (session.target.initialRotation.kind === "direction") { - const direction = new Vector3( - session.target.initialRotation.direction.x, - session.target.initialRotation.direction.y, - session.target.initialRotation.direction.z - ) - .normalize() - .applyAxisAngle(this.axisVector(effectiveAxis).normalize(), (pointerDeltaDegrees * Math.PI) / 180) - .normalize(); + const initialOrientation = new Quaternion().setFromUnitVectors( + new Vector3(0, 1, 0), + new Vector3( + session.target.initialRotation.direction.x, + session.target.initialRotation.direction.y, + session.target.initialRotation.direction.z + ).normalize() + ); + const deltaRotation = new Quaternion().setFromAxisAngle( + this.getConstraintAxisWorldVector(session, effectiveAxis, axisConstraintSpace), + pointerDeltaRadians + ); + const nextOrientation = + axisConstraint !== null && axisConstraintSpace === "local" && supportsLocalTransformAxisConstraint(session, effectiveAxis) + ? initialOrientation.multiply(deltaRotation) + : deltaRotation.multiply(initialOrientation); + const direction = new Vector3(0, 1, 0).applyQuaternion(nextOrientation).normalize(); return { kind: "entity" as const, @@ -3886,6 +4126,7 @@ export class ViewportHost { sourcePanelId: this.panelId, operation: interactionSession.operation, axisConstraint: transformHandle.axisConstraint, + axisConstraintSpace: transformHandle.axisConstraint === null ? "world" : interactionSession.axisConstraintSpace, target: interactionSession.target }), { @@ -3896,7 +4137,8 @@ export class ViewportHost { x: event.clientX, y: event.clientY }, - transformHandle.axisConstraint + transformHandle.axisConstraint, + transformHandle.axisConstraint === null ? "world" : interactionSession.axisConstraintSpace ); this.currentTransformSession = nextSession; @@ -3907,6 +4149,7 @@ export class ViewportHost { pointerId: event.pointerId, sessionId: nextSession.id, axisConstraint: transformHandle.axisConstraint, + axisConstraintSpace: nextSession.axisConstraintSpace, initialClientPosition: { x: event.clientX, y: event.clientY @@ -4022,7 +4265,8 @@ export class ViewportHost { x: event.clientX, y: event.clientY }, - this.activeTransformDrag.axisConstraint + this.activeTransformDrag.axisConstraint, + this.activeTransformDrag.axisConstraintSpace ); this.currentTransformSession = nextSession; @@ -4123,7 +4367,8 @@ export class ViewportHost { x: event.clientX, y: event.clientY }, - this.currentTransformSession.axisConstraint + this.currentTransformSession.axisConstraint, + this.currentTransformSession.axisConstraintSpace ); this.currentTransformSession = nextSession; @@ -4567,6 +4812,7 @@ export class ViewportHost { } this.animationFrame = window.requestAnimationFrame(this.render); + this.updateGridPositioning(); this.updateTransformGizmoPose(); const now = performance.now(); const dt = this.previousFrameTime === 0 ? 0 : Math.min((now - this.previousFrameTime) / 1000, 1 / 20);