Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 42 additions & 6 deletions apps/web/src/components/WorkplaneViewport.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -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;
}
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -8371,6 +8374,7 @@ export function WorkplaneViewport({
if (!state) {
return;
}
state.cameraTurn = null;
orbitCameraByDrag(state, move.dx, move.dy);
syncViewCube(state, viewCubeRef.current);
}, []);
Expand Down Expand Up @@ -8404,6 +8408,7 @@ export function WorkplaneViewport({
if (!state) {
return;
}
state.cameraTurn = null;

const scaled = zoomDistanceScale(scale);
if (scaled < 1) movePivotToDepthUnder(state, 0, 0);
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/components/workplane/ViewportOverlays.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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). */
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/workplane/cameraViews.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
54 changes: 54 additions & 0 deletions apps/web/src/lib/viewCubeTransition.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
2 changes: 2 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
66 changes: 66 additions & 0 deletions tests/unit/viewCubeTransition.test.ts
Original file line number Diff line number Diff line change
@@ -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;
}
});
});