Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor zoom and selection computations with Box3 #1323

Merged
merged 1 commit into from
Dec 19, 2022
Merged
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
29 changes: 9 additions & 20 deletions packages/lib/src/interactions/AxialSelectToZoom.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import type { Axis } from '@h5web/shared';
import { useThree } from '@react-three/fiber';

import { useVisCanvasContext } from '../vis/shared/VisCanvasProvider';
import AxialSelectionTool from './AxialSelectionTool';
import SelectionRect from './SelectionRect';
import { useMoveCameraTo } from './hooks';
import Box from './box';
import { useZoomOnBox } from './hooks';
import type { Selection, CommonInteractionProps } from './models';
import { getEnclosedRectangle } from './utils';

interface Props extends CommonInteractionProps {
axis: Axis;
Expand All @@ -15,26 +14,16 @@ interface Props extends CommonInteractionProps {
function AxialSelectToZoom(props: Props) {
const { axis, modifierKey, disabled } = props;

const { canvasSize, visRatio } = useVisCanvasContext();
const moveCameraTo = useMoveCameraTo();
const { visRatio } = useVisCanvasContext();
const zoomOnBox = useZoomOnBox();
loichuder marked this conversation as resolved.
Show resolved Hide resolved

const { width, height } = canvasSize;
const camera = useThree((state) => state.camera);

function onSelectionEnd(selection: Selection) {
const [worldStart, worldEnd] = selection.world;
function onSelectionEnd({ world: worldSelection }: Selection) {
const [worldStart, worldEnd] = worldSelection;
if (worldStart.x === worldEnd.x || worldStart.y === worldEnd.y) {
return;
}

const zoomRect = getEnclosedRectangle(worldStart, worldEnd);
const { center: zoomRectCenter } = zoomRect;

// Change scale first so that moveCameraTo computes the updated camera bounds
camera.scale.set(zoomRect.width / width, zoomRect.height / height, 1);
camera.updateMatrixWorld();

moveCameraTo(zoomRectCenter);
zoomOnBox(Box.fromPoints(...worldSelection));
loichuder marked this conversation as resolved.
Show resolved Hide resolved
}

return (
Expand All @@ -47,11 +36,11 @@ function AxialSelectToZoom(props: Props) {
>
{({ data: [dataStart, dataEnd] }) => (
<SelectionRect
startPoint={dataStart}
endPoint={dataEnd}
fill="white"
stroke="black"
fillOpacity={0.25}
startPoint={dataStart}
endPoint={dataEnd}
/>
)}
</AxialSelectionTool>
Expand Down
36 changes: 0 additions & 36 deletions packages/lib/src/interactions/RatioSelectionRect.tsx

This file was deleted.

83 changes: 47 additions & 36 deletions packages/lib/src/interactions/SelectToZoom.tsx
Original file line number Diff line number Diff line change
@@ -1,69 +1,80 @@
import { useThree } from '@react-three/fiber';

import { useVisCanvasContext } from '../vis/shared/VisCanvasProvider';
import RatioSelectionRect from './RatioSelectionRect';
import SelectionRect from './SelectionRect';
import SelectionTool from './SelectionTool';
import { useMoveCameraTo } from './hooks';
import type { CommonInteractionProps, Selection } from './models';
import { getEnclosedRectangle, getRatioRespectingRectangle } from './utils';
import Box from './box';
import { useZoomOnBox } from './hooks';
import type { CommonInteractionProps, Rect3, Selection } from './models';

type Props = CommonInteractionProps;

function SelectToZoom(props: Props) {
const { canvasSize, canvasRatio, visRatio, dataToWorld } =
const { canvasSize, canvasRatio, visRatio, visSize, worldToData } =
useVisCanvasContext();

const { width, height } = canvasSize;
const keepRatio = visRatio !== undefined;

const camera = useThree((state) => state.camera);
const moveCameraTo = useMoveCameraTo();
const zoomOnBox = useZoomOnBox();

function onSelectionEnd(selection: Selection) {
const [worldStart, worldEnd] = getRatioRespectingRectangle(
...selection.data,
keepRatio ? canvasRatio : undefined
).map(dataToWorld); // work in world coordinates as we need to act on the world camera
function computeZoomBox(worldSelection: Rect3): Box {
loichuder marked this conversation as resolved.
Show resolved Hide resolved
const { position, scale } = camera;
const zoomBox = Box.fromPoints(...worldSelection);

if (worldStart.x === worldEnd.x || worldStart.y === worldEnd.y) {
return;
if (!keepRatio) {
return zoomBox;
}

const zoomRect = getEnclosedRectangle(worldStart, worldEnd);
const { center: zoomRectCenter } = zoomRect;
const visBox = Box.fromSize(visSize);
const fovBox = Box.empty(position).expandBySize(
width * scale.x,
height * scale.y
);
axelboc marked this conversation as resolved.
Show resolved Hide resolved

return zoomBox
.expandToRatio(canvasRatio)
.keepWithin(visBox) // when zoomed out and canvas/vis ratios differ
.keepWithin(fovBox); // when zoomed in
}

// Change scale first so that moveCameraTo computes the updated camera bounds
camera.scale.set(zoomRect.width / width, zoomRect.height / height, 1);
camera.updateMatrixWorld();
function onSelectionEnd(selection: Selection) {
const [worldStart, worldEnd] = selection.world;
if (worldStart.x === worldEnd.x || worldStart.y === worldEnd.y) {
return;
}
axelboc marked this conversation as resolved.
Show resolved Hide resolved

moveCameraTo(zoomRectCenter);
zoomOnBox(computeZoomBox(selection.world));
loichuder marked this conversation as resolved.
Show resolved Hide resolved
}

return (
<SelectionTool id="SelectToZoom" onSelectionEnd={onSelectionEnd} {...props}>
{({ data: [dataStart, dataEnd] }) => (
<>
<SelectionRect
startPoint={dataStart}
endPoint={dataEnd}
fill="white"
stroke="black"
fillOpacity={keepRatio ? 0 : 0.25}
strokeDasharray={keepRatio ? '4' : undefined}
/>
{keepRatio && (
<RatioSelectionRect
{({ data: [dataStart, dataEnd], world: worldSelection }) => {
const zoomBox = computeZoomBox(worldSelection);

return (
<>
<SelectionRect
startPoint={dataStart}
endPoint={dataEnd}
ratio={canvasRatio}
fillOpacity={0.25}
fill="white"
stroke="black"
fillOpacity={keepRatio ? 0 : 0.25}
strokeDasharray={keepRatio ? '4' : undefined}
/>
)}
</>
)}
{keepRatio && (
<SelectionRect
startPoint={worldToData(zoomBox.min)}
endPoint={worldToData(zoomBox.max)}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

If we end up adding low-level components that accept worldCoords: Rect3, we might not need the extra zoomBox constant and worldToData conversions.

fillOpacity={0.25}
fill="white"
stroke="black"
/>
)}
</>
);
}}
</SelectionTool>
);
}
Expand Down
66 changes: 66 additions & 0 deletions packages/lib/src/interactions/box.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { Box3, Vector3 } from 'three';

import type { Size } from '../vis/models';

const ZERO_VECTOR = new Vector3(0, 0, 0);

class Box extends Box3 {
public get size(): Size {
const { x: width, y: height } = this.getSize(new Vector3());
return { width, height };
loichuder marked this conversation as resolved.
Show resolved Hide resolved
}

public get center(): Vector3 {
return this.getCenter(new Vector3());
loichuder marked this conversation as resolved.
Show resolved Hide resolved
}

public static empty(center = ZERO_VECTOR) {
return new Box(center.clone(), center.clone());
}

public static fromPoints(...points: Vector3[]) {
return new Box().setFromPoints(points);
}

public static fromSize({ width, height }: Size) {
return Box.empty().expandBySize(width, height);
}

public expandBySize(width: number, height: number): this {
return this.expandByVector(new Vector3(width / 2, height / 2, 0));
}

public expandToRatio(ratio: number | undefined): this {
if (ratio === undefined) {
return this;
}

const { width, height } = this.size;
const originalRatio = width / height;

if (originalRatio < ratio) {
return this.expandBySize(height * ratio - width, 0); // increase width
}

return this.expandBySize(0, width / ratio - height); // increase height
}

public keepWithin(area: Box): this {
const { center, size } = this;
const { width: areaWidth, height: areaHeight } = area.size;

const centerClampingBox = Box.empty(area.center).expandBySize(
Math.max(areaWidth - size.width, 0),
Math.max(areaHeight - size.height, 0)
);

const shift = centerClampingBox
.clampPoint(center, new Vector3())
axelboc marked this conversation as resolved.
Show resolved Hide resolved
.sub(center)
.setZ(0); // cancel `z` shift
Copy link
Contributor Author

Choose a reason for hiding this comment

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

The downside of working with Box3 and Vector3: we end up having to correct the z component in a few places so the camera keeps z=5 and doesn't move behind the scene... Perhaps we can revisit this in the future if we think of something clever.

Copy link
Member

Choose a reason for hiding this comment

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

I can live with that


return this.translate(shift);
}
}

export default Box;
65 changes: 40 additions & 25 deletions packages/lib/src/interactions/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,46 +5,62 @@ import { useCallback, useEffect } from 'react';
import { Vector2, Vector3 } from 'three';

import { useVisCanvasContext } from '../vis/shared/VisCanvasProvider';
import { getWorldFOV, htmlToWorld } from '../vis/utils';
import { htmlToWorld } from '../vis/utils';
import { useInteractionsContext } from './InteractionsProvider';
import Box from './box';
import type {
CanvasEvent,
CanvasEventCallbacks,
InteractionEntry,
ModifierKey,
} from './models';
import { clampPositionToArea } from './utils';

const ZOOM_FACTOR = 0.95;
const ONE_VECTOR = new Vector3(1, 1, 1);
const MODIFIER_KEYS: ModifierKey[] = ['Alt', 'Control', 'Shift'];

export function useMoveCameraTo() {
const { visSize } = useVisCanvasContext();
const { canvasSize, visSize } = useVisCanvasContext();

const camera = useThree((state) => state.camera);
const invalidate = useThree((state) => state.invalidate);

return useCallback(
(worldPt: Vector3) => {
const { position } = camera;
const { position, scale } = camera;

const { topRight, bottomLeft } = getWorldFOV(camera);
const width = Math.abs(topRight.x - bottomLeft.x);
const height = Math.abs(topRight.y - bottomLeft.y);

const clampedPosition = clampPositionToArea(
worldPt,
{ width, height },
visSize
);

position.set(clampedPosition.x, clampedPosition.y, position.z);
const visBox = Box.fromSize(visSize);
const fovBox = Box.empty(worldPt)
.expandBySize(canvasSize.width * scale.x, canvasSize.height * scale.y)
.keepWithin(visBox);

position.copy(fovBox.center.setZ(position.z)); // apply new position but keep `z` component as is
camera.updateMatrixWorld();

invalidate();
},
[camera, visSize, invalidate]
[camera, visSize, canvasSize, invalidate]
);
}

export function useZoomOnBox() {
const { canvasSize } = useVisCanvasContext();

const camera = useThree((state) => state.camera);
const moveCameraTo = useMoveCameraTo();

return useCallback(
(zoomBox: Box) => {
const { width, height } = canvasSize;

// Update camera scale first (since `moveCameraTo` relies on camera scale)
const { width: zoomWidth, height: zoomHeight } = zoomBox.size;
camera.scale.set(zoomWidth / width, zoomHeight / height, 1);

// Then move camera position
moveCameraTo(zoomBox.center);
loichuder marked this conversation as resolved.
Show resolved Hide resolved
},
[camera, canvasSize, moveCameraTo]
);
}

Expand Down Expand Up @@ -88,17 +104,16 @@ export function useZoomOnWheel(
// Use `divide` instead of `multiply` by 1 / zoomVector to avoid rounding issues (https://github.com/silx-kit/h5web/issues/1088)
camera.scale.divide(zoomVector).min(ONE_VECTOR);
}
camera.updateMatrixWorld();

// Scale the change in position according to the zoom
const oldPosition = worldPt.clone();
const delta = camera.position.clone().sub(oldPosition);
const scaledDelta =
sourceEvent.deltaY < 0
? delta.multiply(zoomVector)
: delta.divide(zoomVector);
// Scale change in position according to zoom
const delta = camera.position.clone().sub(worldPt);
if (sourceEvent.deltaY < 0) {
delta.multiply(zoomVector);
} else {
delta.divide(zoomVector);
}

moveCameraTo(oldPosition.add(scaledDelta));
moveCameraTo(worldPt.clone().add(delta));
loichuder marked this conversation as resolved.
Show resolved Hide resolved
},
[camera, isZoomAllowed, moveCameraTo]
);
Expand Down
Loading