Skip to content

fix(viewer): gizmo face clicks always snap to canonical orientation#145

Open
zachdive wants to merge 2 commits into
masterfrom
eve/fix-128-view-gizmo-orientation
Open

fix(viewer): gizmo face clicks always snap to canonical orientation#145
zachdive wants to merge 2 commits into
masterfrom
eve/fix-128-view-gizmo-orientation

Conversation

@zachdive
Copy link
Copy Markdown
Contributor

@zachdive zachdive commented May 6, 2026

Closes #128

Summary

Clicking a viewcube face once snapped the camera correctly, but after orbiting and clicking the same face again the camera landed at a drifted angle instead of the canonical orthographic orientation. Affected every face (TOP, FRONT, RIGHT, BACK, LEFT, BOTTOM) in both orthographic and perspective camera modes.

Root cause

@react-three/drei's GizmoHelper (v10.7.7) animates the main camera toward the target orientation frame-by-frame and stops once the angle delta falls below ~0.01 rad:

// drei/core/GizmoHelper.js
mainCamera.position.set(0, 0, 1).applyQuaternion(q1).multiplyScalar(radius.current).add(focusPoint.current);
mainCamera.up.set(0, 1, 0).applyQuaternion(q1).normalize();   // <-- transient rotated up
mainCamera.quaternion.copy(q1);
if (onUpdate) onUpdate();
else if (defaultControls) defaultControls.update(delta);       // <-- ends in camera.lookAt(target)
// ...
if (q1.angleTo(q2) < 0.01) {                                   // <-- 0.01 rad threshold
  animating.current = false;
  if (isOrbitControls(defaultControls)) {
    mainCamera.up.copy(defaultUp.current);                     // <-- up reset, but not lookAt
  }
}

Two interacting failures produce the visible drift:

  1. Up-vector contamination during animation. Each frame, drei rotates camera.up along with the interpolated quaternion before calling OrbitControls.update(). Inside that update, camera.lookAt(target) derives the final orientation from the transient rotated up — so camera.quaternion ends every frame off the canonical axis.
  2. Threshold + completion mismatch. When q1.angleTo(q2) < 0.01, drei flips animating off and resets camera.up = defaultUp but does not redo a canonical lookAt. The camera keeps the slightly-tilted quaternion produced by the previous frame's controls.update() and a slightly-off position from the under-converged q1. After an orbit, the next click of the same face either short-circuits at the threshold (no movement, drift preserved) or animates toward the still-tilted state.

Because OrbitControls damping is enabled, the up vector drifts further during orbit, which is why the bug only surfaces after the user has moved the camera.

Fix

Bypass drei's animation entirely. A new ViewGizmo component (src/components/viewer/ViewGizmo.tsx) wraps GizmoHelper + GizmoViewcube and passes a custom onClick to GizmoViewcube. The handler:

  1. Recovers the clicked direction from the event — e.face.normal for the central face cube, eventObject.position.normalize() for edge/corner cubes (those carry a non-origin local position pointing outward).
  2. Reads the current OrbitControls.target (preserving any panning) and the camera-to-target distance as radius.
  3. Pins camera.up = (0, 1, 0), sets camera.position = target + direction * radius, calls camera.lookAt(target), and triggers controls.update() so OrbitControls' internal spherical state is reconciled.

Every face/edge/corner click now lands on the same deterministic axis-aligned view in both orthographic and perspective modes, regardless of orbit history.

Files changed

  • new: src/components/viewer/ViewGizmo.tsx — drop-in wrapper around GizmoHelper/GizmoViewcube with the canonical-snap onClick handler.
  • src/components/viewer/ThreeScene.tsx — uses ViewGizmo.
  • src/components/viewer/MeshPreview.tsx — uses ViewGizmo (same gizmo bug existed here).

No new dependencies. No changes to OrbitControls configuration or initial camera positions.

Manual test steps

  1. Open a parametric model in the viewer (e.g. one with linear_extrude).
  2. Click TOP — verify camera snaps to top-down.
  3. Orbit the model with click-and-drag; spin around several times.
  4. Click TOP again — verify the camera lands at the same top-down orientation as step 2.
  5. Repeat for FRONT, RIGHT, BACK, LEFT, BOTTOM.
  6. Click an edge or corner of the viewcube — verify it lands on the canonical 3/4 view and re-clicking after orbit still lands at the same view.
  7. Toggle orthographic ↔ perspective via the bottom-right toggle and repeat.
  8. Confirm the same behavior in the mesh-upload viewer (MeshPreview, top-left gizmo).

Validation

  • npm run typecheck — passes for the touched files. One pre-existing error in src/utils/meshPrintProcessUtils.ts:968 (DataView<ArrayBufferLike> not assignable to BlobPart) reproduces on master and is unrelated.
  • npm run lint — 0 errors, same 12 pre-existing warnings as master.
  • npm run build — fails on the same pre-existing meshPrintProcessUtils.ts typecheck error as master. Unrelated to this change.

Authored by Eve (Zach's AI agent) on behalf of Adam.

@vercel
Copy link
Copy Markdown

vercel Bot commented May 6, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cadam Ready Ready Preview, Comment May 12, 2026 0:26am

Request Review

@supabase
Copy link
Copy Markdown

supabase Bot commented May 6, 2026

This pull request has been ignored for the connected project sgprnbvihmydyrzvkcir because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 3 files

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 7, 2026

Greptile Summary

Fixes the viewcube face-click drift bug (#128) by bypassing drei's frame-by-frame animation entirely and replacing it with a single deterministic snap in a new ViewGizmo wrapper component.

  • ViewGizmo.tsx: New component wrapping GizmoHelper/GizmoViewcube. A custom onClick handler recovers the click direction from e.face.normal (face cubes) or eventObject.position (edge/corner cubes), then sets camera.up, camera.position, calls camera.lookAt(target), and reconciles OrbitControls state — all in one synchronous call, with no animation threshold to drift under.
  • ThreeScene.tsx / MeshPreview.tsx: Mechanical drop-in replacements of the old GizmoHelper+GizmoViewcube JSX with <ViewGizmo>, preserving existing alignment and margin values.

Confidence Score: 5/5

Safe to merge. The snap logic is deterministic, all three call sites are covered, and the OrbitControls reconciliation pattern is correct.

The new component handles the degenerate TOP/BOTTOM up-vector case, uses a proper type guard instead of a cast, and the camera-snap sequence (set position then lookAt then controls.update) correctly reconciles OrbitControls internal state. No correctness issues remain; only cosmetic nits.

No files require special attention beyond the two style nits in ViewGizmo.tsx.

Important Files Changed

Filename Overview
src/components/viewer/ViewGizmo.tsx New component bypassing drei's animation with a direct camera snap. Logic is correct and all previous-thread issues are addressed; two minor style nits remain (wrong version in @ts-expect-error, missing import-group blank line).
src/components/viewer/ThreeScene.tsx Swaps GizmoHelper/GizmoViewcube inline usage for ViewGizmo; default props match the old alignment/margin values exactly.
src/components/viewer/MeshPreview.tsx Same mechanical swap with explicit alignment/margin forwarded; no behaviour change outside the gizmo click fix.

Sequence Diagram

sequenceDiagram
    participant User
    participant GizmoViewcube
    participant ViewGizmo
    participant Camera
    participant OrbitControls

    User->>GizmoViewcube: click face / edge / corner
    GizmoViewcube->>ViewGizmo: onClick(ThreeEvent)
    ViewGizmo->>ViewGizmo: recover direction (face normal or eventObject.position)
    ViewGizmo->>OrbitControls: read target and camera distance
    ViewGizmo->>Camera: set up vector (non-degenerate)
    ViewGizmo->>Camera: "set position = target + direction x radius"
    ViewGizmo->>Camera: lookAt(target)
    ViewGizmo->>OrbitControls: update() reconcile spherical state
    ViewGizmo->>Camera: invalidate() request render
Loading

Reviews (3): Last reviewed commit: "fix(viewer): harden view gizmo snap hand..." | Re-trigger Greptile

Comment thread src/components/viewer/ViewGizmo.tsx Outdated
Comment thread src/components/viewer/ViewGizmo.tsx Outdated
Comment thread src/components/viewer/ViewGizmo.tsx Outdated
Comment thread src/components/viewer/ViewGizmo.tsx Outdated
Comment thread src/components/viewer/ViewGizmo.tsx
Eve and others added 2 commits May 11, 2026 17:22
…128)

After clicking a viewcube face once, the camera snapped correctly. After
orbiting and clicking the same face again, the camera landed at a drifted
angle instead of the canonical orthographic orientation. Affected every
face (TOP, FRONT, RIGHT, BACK, LEFT, BOTTOM) in both orthographic and
perspective camera modes.

Root cause: drei's GizmoHelper tween rotates camera.up along with the
interpolated quaternion each animation frame and stops once the angle
delta falls below ~0.01 rad. During the animation OrbitControls.update()
is invoked with that transient rotated up, and its final
camera.lookAt(target) bakes the tilted up into camera.quaternion. On
completion drei resets camera.up to defaultUp without redoing a
canonical lookAt, leaving camera.up, camera.position, and
camera.quaternion subtly out of sync. Subsequent clicks of the same
face either short-circuit at the threshold (no movement, drift
preserved) or animate toward the still-tilted state.

Fix: bypass drei's animation entirely. ViewGizmo passes a custom onClick
to GizmoViewcube that recovers the clicked direction (face normal for
face cubes, the cube's outward local position for edge/corner cubes)
and snaps the main camera directly to target + direction * radius with
camera.up pinned to world-Y. camera.lookAt(target) and controls.update()
then leave OrbitControls' internal spherical state reconciled with the
canonical orientation. Every face click now lands at the same
deterministic axis-aligned view regardless of orbit history, in both
orthographic and perspective modes.

Closes #128

Authored by Eve (Zach's AI agent) on behalf of Adam.

Co-authored-by: eve-app <280557408+eve-app@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

1 participant