From 75ba14c508e575ceb95d934c4db8b07ff5e1e418 Mon Sep 17 00:00:00 2001 From: ck Date: Thu, 22 Oct 2026 06:12:28 +0800 Subject: [PATCH] Turn the view smoothly when the view cube is clicked A click on a face, edge or corner of the view cube cut the camera there in one frame. The camera now turns around the orbit pivot in about a third of a second: the distance stays, the azimuth takes the shorter way round, and an ease-out settles into the clicked view. Any camera input, Home, zoom, focus selection, a saved view or a projection switch takes over and ends the turn. The AI bridge's view capture still sets the view immediately. Unit tests in viewCubeTransition.test.ts; sampled frame by frame in the dev server. --- apps/web/src/components/WorkplaneViewport.tsx | 48 ++++++++++++-- .../components/workplane/ViewportOverlays.tsx | 3 + .../src/components/workplane/cameraViews.ts | 1 + apps/web/src/lib/viewCubeTransition.ts | 54 +++++++++++++++ docs/CHANGELOG.md | 2 + tests/unit/viewCubeTransition.test.ts | 66 +++++++++++++++++++ 6 files changed, 168 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/lib/viewCubeTransition.ts create mode 100644 tests/unit/viewCubeTransition.test.ts diff --git a/apps/web/src/components/WorkplaneViewport.tsx b/apps/web/src/components/WorkplaneViewport.tsx index d9ebe9c95..1f9c682eb 100644 --- a/apps/web/src/components/WorkplaneViewport.tsx +++ b/apps/web/src/components/WorkplaneViewport.tsx @@ -51,6 +51,7 @@ import { type ViewCubeDrag, type ViewCubeFace, } from "@/lib/viewCubeDrag"; +import { cameraTurn, cameraTurnOffset } from "@/lib/viewCubeTransition"; import { createGearGeometry } from "@/lib/gearGeometry"; import { createStarGeometry } from "@/lib/starGeometry"; import { createHeartGeometry } from "@/lib/heartGeometry"; @@ -4545,15 +4546,15 @@ export function WorkplaneViewport({ renderFrame(state); return thumbnailPngDataUrl(state.renderer.domElement); }; - window.layerlingOrbitView = (azimuthDegrees, polarDegrees) => { orbitViewBy(state, azimuthDegrees, polarDegrees); state.needsRender = true; }; - window.layerlingPanView = (rightFraction, upFraction) => { panViewBy(state, rightFraction, upFraction); state.needsRender = true; }; + window.layerlingOrbitView = (azimuthDegrees, polarDegrees) => { state.cameraTurn = null; orbitViewBy(state, azimuthDegrees, polarDegrees); state.needsRender = true; }; + window.layerlingPanView = (rightFraction, upFraction) => { state.cameraTurn = null; panViewBy(state, rightFraction, upFraction); state.needsRender = true; }; window.layerlingCaptureView = (face = "current") => { if (face === "home") { resetCamera(state); } else if (face !== "current") { const direction = viewDirectionFromName(face); if (!direction) throw new Error(`Unknown view "${face}": use a face, or two or three neighbouring faces joined with "-", like "front-right-top"`); - setCameraToViewDirection(state, direction); + setCameraToViewDirection(state, direction, false); } syncViewCube(state, viewCubeRef.current); fitCameraDepthRange(state.camera, state.controls.target); @@ -4649,10 +4650,11 @@ export function WorkplaneViewport({ const animate = () => { state.animationId = window.requestAnimationFrame(animate); const now = performance.now(); + const turning = stepCameraTurn(state, now); const controlsChanged = state.controls.update(); fitCameraDepthRange(state.camera, state.controls.target); const cameraSettled = state.wasCameraMoving && !controlsChanged; - if (!controlsChanged && !state.needsRender && !cameraSettled) { + if (!turning && !controlsChanged && !state.needsRender && !cameraSettled) { return; } constrainCamera(state, workspaceRef.current); @@ -4661,7 +4663,7 @@ export function WorkplaneViewport({ // can read the previous matrix unless we force the matrix world current here. // Removing this brings back the one-frame-late handle/line lag during camera motion. state.camera.updateMatrixWorld(); - if (now - state.lastViewCubeSync > 48 || cameraSettled || state.needsRender) { + if (turning || now - state.lastViewCubeSync > 48 || cameraSettled || state.needsRender) { syncViewCube(state, viewCubeRef.current); state.lastViewCubeSync = now; } @@ -8262,6 +8264,7 @@ export function WorkplaneViewport({ const focusSelection = useCallback((): boolean => { const state = threeRef.current; if (!state) return false; + state.cameraTurn = null; // The real selection, not renderSelectionIds: the workplane and edge tools hide // the outline while they are open, but the objects are still what to look at. const bounds = new THREE.Box3(); @@ -8371,6 +8374,7 @@ export function WorkplaneViewport({ if (!state) { return; } + state.cameraTurn = null; orbitCameraByDrag(state, move.dx, move.dy); syncViewCube(state, viewCubeRef.current); }, []); @@ -8404,6 +8408,7 @@ export function WorkplaneViewport({ if (!state) { return; } + state.cameraTurn = null; const scaled = zoomDistanceScale(scale); if (scaled < 1) movePivotToDepthUnder(state, 0, 0); @@ -9996,6 +10001,11 @@ function createThreeScene(host: HTMLDivElement): ThreeState { wheelDeltaOverride = null; }; controls.addEventListener("change", requestRender); + // Any camera input (drag, wheel, touch) takes over from a view cube turn that is still running. + const cancelCameraTurn = () => { + state.cameraTurn = null; + }; + controls.addEventListener("start", cancelCameraTurn); SAFARI_GESTURES.forEach((name) => renderer.domElement.addEventListener(name, preventSafariGesture)); renderer.domElement.addEventListener("pointerdown", configureLayerlingMouseButtons, { capture: true }); renderer.domElement.addEventListener("pointerup", resetLayerlingMouseButtons); @@ -10016,6 +10026,7 @@ function createThreeScene(host: HTMLDivElement): ThreeState { renderer.domElement.removeEventListener("wheel", countWheelNotch, { capture: true }); renderer.domElement.removeEventListener("wheel", endWheelNotch); controls.removeEventListener("change", requestRender); + controls.removeEventListener("start", cancelCameraTurn); renderer.domElement.removeEventListener("pointerdown", configureLayerlingMouseButtons, { capture: true }); renderer.domElement.removeEventListener("pointerup", resetLayerlingMouseButtons); renderer.domElement.removeEventListener("pointercancel", resetLayerlingMouseButtons); @@ -10029,6 +10040,7 @@ function createThreeScene(host: HTMLDivElement): ThreeState { } function resetCamera(state: ThreeState) { + state.cameraTurn = null; state.focusReach = null; state.camera.up.set(0, 1, 0); state.camera.position.copy(CAMERA_HOME); @@ -10072,6 +10084,7 @@ function updateOrthographicFrustum(camera: THREE.OrthographicCamera, aspect: num } function toggleCameraProjection(state: ThreeState) { + state.cameraTurn = null; const current = state.camera; const target = state.controls.target; const canvas = state.renderer.domElement; @@ -10129,10 +10142,19 @@ function setCameraToViewFace(state: ThreeState, face: ViewCubeFace) { } /** Looks at the orbit target from a unit direction, keeping the distance: a face, an edge or a corner of the view cube. */ -function setCameraToViewDirection(state: ThreeState, direction: THREE.Vector3) { +function setCameraToViewDirection(state: ThreeState, direction: THREE.Vector3, animate = true) { const offset = state.camera.position.clone().sub(state.controls.target); const distance = clamp(offset.length(), CAMERA_MIN_DISTANCE, CAMERA_MAX_DISTANCE); + if (animate) { + // A click on the cube turns the view; the render loop eases the camera there and keeps + // drawing while it does. A second click starts from wherever the first has got to. + state.cameraTurn = cameraTurn(offset, direction.clone().normalize().multiplyScalar(distance), performance.now()); + state.needsRender = true; + return; + } + + state.cameraTurn = null; state.camera.up.set(0, 1, 0); state.camera.position.copy(state.controls.target).add(direction.multiplyScalar(distance)); state.camera.lookAt(state.controls.target); @@ -10141,8 +10163,22 @@ function setCameraToViewDirection(state: ThreeState, direction: THREE.Vector3) { state.needsRender = true; } +/** One frame of the view cube turn, before OrbitControls orients the camera. */ +function stepCameraTurn(state: ThreeState, now: number): boolean { + const turn = state.cameraTurn; + if (!turn) return false; + const { offset, done } = cameraTurnOffset(turn, now); + state.camera.up.set(0, 1, 0); + state.camera.position.copy(state.controls.target).add(offset); + state.camera.lookAt(state.controls.target); + state.camera.updateProjectionMatrix(); + if (done) state.cameraTurn = null; + return true; +} + /** Orbits the camera around the controls target for a drag on the view cube. */ function orbitCameraByDrag(state: ThreeState, dx: number, dy: number) { + state.cameraTurn = null; const offset = orbitOffsetByDrag(state.camera.position.clone().sub(state.controls.target), dx, dy); state.camera.up.set(0, 1, 0); diff --git a/apps/web/src/components/workplane/ViewportOverlays.tsx b/apps/web/src/components/workplane/ViewportOverlays.tsx index 5d976f054..38feb825e 100644 --- a/apps/web/src/components/workplane/ViewportOverlays.tsx +++ b/apps/web/src/components/workplane/ViewportOverlays.tsx @@ -1,6 +1,7 @@ import { Crosshair, Eye, EyeOff, Plus, Rows3, X } from "lucide-react"; import { useLayoutEffect, useRef, useState, type Dispatch, type MutableRefObject, type PointerEvent as ReactPointerEvent, type SetStateAction } from "react"; +import type { CameraTurn } from "@/lib/viewCubeTransition"; import * as THREE from "three"; import { disposeBoundsTree } from "three-mesh-bvh"; import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; @@ -91,6 +92,8 @@ export type ThreeState = { animationId: number; needsRender: boolean; wasCameraMoving: boolean; + /** The view cube turn under way: the camera eases to the clicked face instead of jumping. */ + cameraTurn?: CameraTurn | null; lastOverlaySync: number; lastViewCubeSync: number; /** True while a handle of the align tool shows its preview: the drawn parts have moved then (#235). */ diff --git a/apps/web/src/components/workplane/cameraViews.ts b/apps/web/src/components/workplane/cameraViews.ts index 406c65c9d..4e18d7811 100644 --- a/apps/web/src/components/workplane/cameraViews.ts +++ b/apps/web/src/components/workplane/cameraViews.ts @@ -62,6 +62,7 @@ function cameraViewOf(state: ThreeState): DesignView { } function applyCameraView(state: ThreeState, view: DesignView) { + state.cameraTurn = null; state.camera.up.fromArray(view.up); state.camera.position.fromArray(view.position); state.controls.target.fromArray(view.target); diff --git a/apps/web/src/lib/viewCubeTransition.ts b/apps/web/src/lib/viewCubeTransition.ts new file mode 100644 index 000000000..07f1f5f86 --- /dev/null +++ b/apps/web/src/lib/viewCubeTransition.ts @@ -0,0 +1,54 @@ +import * as THREE from "three"; +import { VIEW_CUBE_POLE_EPSILON } from "@/lib/viewCubeDrag"; + +/** + * A click on the view cube turns the camera from where it stands to the clicked face, edge or + * corner instead of jumping there. The turn runs on the orbit sphere around the pivot: the + * radius stays, the angles take the shortest way, and the eye eases out into the new view. + */ +export const VIEW_CUBE_TURN_MS = 320; + +export type CameraTurn = { + from: THREE.Spherical; + to: THREE.Spherical; + startedAt: number; + duration: number; +}; + +/** Ease-out cubic: quick at the first click, gentle as the view settles. */ +export function turnEase(progress: number): number { + const left = 1 - THREE.MathUtils.clamp(progress, 0, 1); + return 1 - left * left * left; +} + +/** + * The turn from the camera's current offset to a new one. The distance is kept, and the + * azimuth takes the shorter way round, so a click never swings the long way (and never + * through the pivot, as a straight line between two positions would). + */ +export function cameraTurn(fromOffset: THREE.Vector3, toOffset: THREE.Vector3, startedAt: number, duration = VIEW_CUBE_TURN_MS): CameraTurn { + const from = new THREE.Spherical().setFromVector3(fromOffset); + const to = new THREE.Spherical().setFromVector3(toOffset); + to.radius = from.radius; + to.phi = THREE.MathUtils.clamp(to.phi, VIEW_CUBE_POLE_EPSILON, Math.PI - VIEW_CUBE_POLE_EPSILON); + let theta = to.theta; + if (theta - from.theta > Math.PI) theta -= Math.PI * 2; + else if (theta - from.theta < -Math.PI) theta += Math.PI * 2; + to.theta = theta; + return { from, to, startedAt, duration: Math.max(1, duration) }; +} + +/** + * The camera offset at `now`, and whether the turn has arrived. The ends are exact, so a + * turn that is cut short by a drag never leaves the camera between two views. + */ +export function cameraTurnOffset(turn: CameraTurn, now: number): { offset: THREE.Vector3; done: boolean } { + const progress = THREE.MathUtils.clamp((now - turn.startedAt) / turn.duration, 0, 1); + const eased = turnEase(progress); + const spherical = new THREE.Spherical( + THREE.MathUtils.lerp(turn.from.radius, turn.to.radius, eased), + THREE.MathUtils.lerp(turn.from.phi, turn.to.phi, eased), + THREE.MathUtils.lerp(turn.from.theta, turn.to.theta, eased), + ).makeSafe(); + return { offset: new THREE.Vector3().setFromSpherical(spherical), done: progress >= 1 }; +} diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 1c4d8b241..ea24b2b5a 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -6,6 +6,8 @@ code still carries it - so a lower number further down is older, not newer. ## Unreleased +- **A click on the view cube turns the view instead of jumping:** A face, edge or corner used to cut the camera there in one frame, which read as a blink and made it hard to follow where the view had gone. The camera now turns around the orbit pivot in about a third of a second: the distance stays, the azimuth takes the shorter way round, an ease-out cubic settles into the clicked view, and the cube itself follows frame by frame. A second click starts from wherever the first has got to; any camera input - a drag on the canvas or the cube, a wheel, a touch - takes over and ends the turn. The AI bridge's `layerlingCaptureView` still sets the view before it captures, unchanged. `lib/viewCubeTransition.ts`, unit tests in `viewCubeTransition.test.ts`; the turn was sampled in the dev server frame by frame (front: 250 ms to the last degree, then still; top: 89.9943° as before). + ## 1.99.0 - **Rounded box radii to 0.01 mm (#705):** `normalizeCornerFillet` and `normalizeTopBottomFillet` rounded to a tenth of a millimetre, so a corner fillet of 0.25 mm came back as 0.3 mm. They round to a hundredth now, and the fields (the shape's properties and the defaults in the workspace settings) step by 0.05. The CAD solid reads the same functions, so it follows. Unit test in `roundedBoxGeometry.test.ts` (0.25 stays 0.25, 7.336 becomes 7.34). Reported by @Algotz on GitHub. diff --git a/tests/unit/viewCubeTransition.test.ts b/tests/unit/viewCubeTransition.test.ts new file mode 100644 index 000000000..18c5d9348 --- /dev/null +++ b/tests/unit/viewCubeTransition.test.ts @@ -0,0 +1,66 @@ +import * as THREE from "three"; +import { describe, expect, it } from "vitest"; +import { cameraTurn, cameraTurnOffset, turnEase, VIEW_CUBE_TURN_MS } from "@/lib/viewCubeTransition"; + +const X = new THREE.Vector3(1, 0, 0); +const FRONT = new THREE.Vector3(0, 0, 1); +const TOP = new THREE.Vector3(0, 1, 0); + +describe("a turn of the camera from the view cube", () => { + it("starts exactly where the camera stands and ends exactly on the clicked side", () => { + const turn = cameraTurn(new THREE.Vector3(7, 9, 11), FRONT.clone().multiplyScalar(new THREE.Vector3(7, 9, 11).length()), 1000); + const start = cameraTurnOffset(turn, 1000); + const end = cameraTurnOffset(turn, 1000 + VIEW_CUBE_TURN_MS); + expect(start.offset.distanceTo(new THREE.Vector3(7, 9, 11))).toBeLessThan(1e-9); + expect(start.done).toBe(false); + expect(end.done).toBe(true); + expect(end.offset.clone().normalize().distanceTo(FRONT)).toBeLessThan(1e-9); + }); + + it("keeps the distance to the pivot, also halfway", () => { + const turn = cameraTurn(new THREE.Vector3(20, 20, 20), TOP.clone().multiplyScalar(34.641), 0); + for (const at of [0, 0.25, 0.5, 0.75, 1]) { + const { offset } = cameraTurnOffset(turn, at * VIEW_CUBE_TURN_MS); + expect(offset.length()).toBeCloseTo(Math.hypot(20, 20, 20), 6); + } + }); + + it("takes the shorter way round, not the long one", () => { + const from = new THREE.Vector3(Math.sin(3), 0, Math.cos(3)).multiplyScalar(10); + const to = new THREE.Vector3(Math.sin(-3), 0, Math.cos(-3)).multiplyScalar(10); + const turn = cameraTurn(from, to, 0); + expect(turn.to.theta - turn.from.theta).toBeCloseTo(Math.PI * 2 - 6, 6); + expect(turn.to.theta - turn.from.theta).toBeLessThan(Math.PI); + expect(cameraTurnOffset(turn, VIEW_CUBE_TURN_MS).offset.length()).toBeCloseTo(10, 6); + }); + + it("goes round the side between opposite faces, never through the pivot", () => { + const turn = cameraTurn(FRONT.clone().multiplyScalar(10), new THREE.Vector3(0, 0, -10), 0); + // Half the way after easing, not half the time: the turn eases out. + const middle = cameraTurnOffset(turn, VIEW_CUBE_TURN_MS * (1 - Math.cbrt(0.5))).offset; + expect(middle.length()).toBeCloseTo(10, 6); + expect(Math.abs(middle.y)).toBeLessThan(1e-9); + expect(middle.x).toBeGreaterThan(9); + }); + + it("keeps a hair off the pole, where the view has no defined up", () => { + const turn = cameraTurn(X.clone().multiplyScalar(10), TOP.clone().multiplyScalar(10), 0); + const end = cameraTurnOffset(turn, VIEW_CUBE_TURN_MS).offset; + expect(end.length()).toBeCloseTo(10, 6); + expect(end.y).toBeGreaterThan(9.9999); + expect(Math.hypot(end.x, end.z)).toBeGreaterThan(0); + }); + + it("eases out and never overshoots", () => { + expect(turnEase(0)).toBe(0); + expect(turnEase(0.5)).toBeCloseTo(0.875, 9); + expect(turnEase(1)).toBe(1); + let previous = 0; + for (let step = 0; step <= 20; step += 1) { + const value = turnEase(step / 20); + expect(value).toBeGreaterThanOrEqual(previous); + expect(value).toBeLessThanOrEqual(1); + previous = value; + } + }); +});