Skip to content

Commit

Permalink
Merge pull request #299 from allen-cell-animated/feature/camera-urls
Browse files Browse the repository at this point in the history
- Updates `url_utils` to support saving and parsing the camera's position, rotation, target position, and up vector to and from the URL.
- Hoists the `view3d` as a ref up to the top-level `AppWrapper`, so that the current camera transform information can be accessed as soon as the user hits the "Share URL" modal.
  • Loading branch information
ShrimpCryptid authored Aug 13, 2024
2 parents 8c240a9 + b4fbc0a commit 8f14ffd
Show file tree
Hide file tree
Showing 10 changed files with 135 additions and 17 deletions.
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
"author": "Megan Riel-Mehan",
"license": "ISC",
"dependencies": {
"@aics/volume-viewer": "^3.10.0",
"@aics/volume-viewer": "^3.11.0",
"@ant-design/icons": "^5.2.5",
"@fortawesome/fontawesome-svg-core": "^6.5.2",
"@fortawesome/free-solid-svg-icons": "^6.5.2",
Expand Down
14 changes: 13 additions & 1 deletion src/aics-image-viewer/components/App/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ const defaultViewerSettings: ViewerState = {
region: { x: [0, 1], y: [0, 1], z: [0, 1] },
slice: { x: 0.5, y: 0.5, z: 0.5 },
time: 0,
cameraState: undefined,
};

const DEFAULT_CHANNEL_STATE: ChannelState = {
Expand Down Expand Up @@ -156,6 +157,7 @@ const defaultProps: AppProps = {
parentImageDownloadHref: "",
pixelSize: undefined,
canvasMargin: "0 0 0 0",
view3dRef: undefined,
};

const axisToLoaderPriority: Record<AxisName | "t", PrefetchDirection> = {
Expand Down Expand Up @@ -227,6 +229,9 @@ const App: React.FC<AppProps> = (props) => {
viewerState.current;

const view3d = useConstructor(() => new View3d());
if (props.view3dRef !== undefined) {
props.view3dRef.current = view3d;
}
const loadContext = useConstructor(
() => new VolumeLoaderContext(CACHE_MAX_SIZE, QUEUE_MAX_SIZE, QUEUE_MAX_LOW_PRIORITY_SIZE)
);
Expand All @@ -239,6 +244,7 @@ const App: React.FC<AppProps> = (props) => {
_showError(error);
setSendingQueryRequest(false);
};

useEffect(() => {
// Get notifications of loading errors which occur after the initial load, e.g. on time change or new channel load
view3d.setLoadErrorHandler((_vol, e) => showError(e));
Expand Down Expand Up @@ -594,7 +600,6 @@ const App: React.FC<AppProps> = (props) => {
};

// Effects to imperatively sync `viewerSettings` to `view3d`

useImageEffect(
(_currentImage) => {
view3d.setCameraMode(viewerSettings.viewMode);
Expand All @@ -603,6 +608,13 @@ const App: React.FC<AppProps> = (props) => {
[viewerSettings.viewMode]
);

useImageEffect((_currentImage) => {
// Set camera transform on initial load only
if (viewerSettings.cameraState) {
view3d.setCameraState(viewerSettings.cameraState);
}
}, []);

useImageEffect((_currentImage) => view3d.setAutoRotate(viewerSettings.autorotate), [viewerSettings.autorotate]);

useImageEffect((_currentImage) => view3d.setShowAxis(viewerSettings.showAxes), [viewerSettings.showAxes]);
Expand Down
4 changes: 3 additions & 1 deletion src/aics-image-viewer/components/App/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { RawArrayData, RawArrayInfo, Volume } from "@aics/volume-viewer";
import type { RawArrayData, RawArrayInfo, View3d, Volume } from "@aics/volume-viewer";
import { MutableRefObject } from "react";

import type { MetadataRecord } from "../../shared/types";
import type { ViewerChannelSettings } from "../../shared/utils/viewerChannelSettings";
Expand Down Expand Up @@ -55,6 +56,7 @@ export interface AppProps {
};
metadata?: MetadataRecord;

view3dRef?: MutableRefObject<View3d | null>;
metadataFormatter?: (metadata: MetadataRecord) => MetadataRecord;
onControlPanelToggle?: (collapsed: boolean) => void;
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ const DEFAULT_VIEWER_SETTINGS: ViewerState = {
region: { x: [0, 1], y: [0, 1], z: [0, 1] },
slice: { x: 0.5, y: 0.5, z: 0.5 },
time: 0,
cameraState: undefined,
};

// Some viewer settings require custom change behaviors to change related settings simultaneously or guard against
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ControlPoint } from "@aics/volume-viewer";
import { CameraState, ControlPoint } from "@aics/volume-viewer";
import type { ImageType, RenderMode, ViewMode } from "../../shared/enums";
import type { PerAxis } from "../../shared/types";
import type { ColorArray } from "../../shared/utils/colorRepresentations";
Expand Down Expand Up @@ -26,6 +26,7 @@ export interface ViewerState {
// This state is active in x,y,z single slice modes.
slice: PerAxis<number>;
time: number;
cameraState: Partial<CameraState> | undefined;
}

export type ViewerStateKey = keyof ViewerState;
Expand Down
11 changes: 9 additions & 2 deletions website/components/AppWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { ImageViewerApp, ViewerStateProvider } from "../../src";
import { ViewerState } from "../../src/aics-image-viewer/components/ViewerStateProvider/types";
import { AppDataProps } from "../types";
import { parseViewerUrlParams } from "../utils/url_utils";
import { View3d } from "@aics/volume-viewer";

const DEFAULT_APP_PROPS: AppDataProps = {
imageUrl: "",
Expand Down Expand Up @@ -37,6 +38,7 @@ export default function AppWrapper(): ReactElement {
const location = useLocation();
const navigation = useNavigate();

const view3dRef = React.useRef<View3d | null>(null);
const [viewerSettings, setViewerSettings] = useState<Partial<ViewerState>>({});
const [viewerProps, setViewerProps] = useState<AppDataProps | null>(null);
const [searchParams] = useSearchParams();
Expand Down Expand Up @@ -94,13 +96,18 @@ export default function AppWrapper(): ReactElement {
<FlexRowAlignCenter $gap={12}>
<FlexRowAlignCenter $gap={2}>
<LoadModal onLoad={onLoad} />
{viewerProps && <ShareModal appProps={viewerProps} />}
{viewerProps && <ShareModal appProps={viewerProps} view3dRef={view3dRef} />}
</FlexRowAlignCenter>
<HelpDropdown />
</FlexRowAlignCenter>
</Header>
{viewerProps && (
<ImageViewerApp {...viewerProps} appHeight={`calc(100vh - ${HEADER_HEIGHT_PX}px)`} canvasMargin="0 0 0 0" />
<ImageViewerApp
{...viewerProps}
appHeight={`calc(100vh - ${HEADER_HEIGHT_PX}px)`}
canvasMargin="0 0 0 0"
view3dRef={view3dRef}
/>
)}
</ViewerStateProvider>
</div>
Expand Down
9 changes: 8 additions & 1 deletion website/components/Modals/ShareModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,12 @@ import {
} from "../../../src/aics-image-viewer/components/ViewerStateProvider";
import { ViewerStateContextType } from "../../../src/aics-image-viewer/components/ViewerStateProvider/types";
import { serializeViewerUrlParams } from "../../utils/url_utils";
import { View3d } from "@aics/volume-viewer";

type ShareModalProps = {
appProps: AppDataProps;
// Used to retrieve the current camera position information
view3dRef?: React.RefObject<View3d | null>;
} & ViewerStateContextType;

const ModalContainer = styled.div``;
Expand All @@ -30,7 +33,11 @@ const ShareModal: React.FC<ShareModalProps> = (props: ShareModalProps) => {

// location.pathname will include up to `.../viewer`
const baseUrl = location.protocol + "//" + location.host + location.pathname;
let serializedViewerParams = serializeViewerUrlParams(props) as Record<string, string>;
const paramProps = {
...props,
cameraState: props.view3dRef?.current?.getCameraState(),
};
let serializedViewerParams = serializeViewerUrlParams(paramProps) as Record<string, string>;

if (props.appProps.imageUrl) {
let serializedUrl;
Expand Down
21 changes: 21 additions & 0 deletions website/utils/test/url_utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,7 @@ describe("Viewer state serialization", () => {
region: { x: [0, 1], y: [0, 1], z: [0, 1] },
slice: { x: 0.5, y: 0.5, z: 0.5 },
time: 0,
cameraState: undefined,
};
const SERIALIZED_DEFAULT_VIEWER_STATE: ViewerStateParams = {
mode: "volumetric",
Expand Down Expand Up @@ -413,6 +414,13 @@ describe("Viewer state serialization", () => {
region: { x: [0, 0.5], y: [0, 1], z: [0, 1] },
slice: { x: 0.25, y: 0.75, z: 0.5 },
time: 100,
cameraState: {
position: [-1.05, -4, 45],
target: [0, 0, 0],
up: [0, 1, 0],
orthoScale: 3.534,
fov: 43.5,
},
};
const SERIALIZED_CUSTOM_VIEWER_STATE: ViewerStateParams = {
mode: "pathtrace",
Expand All @@ -431,6 +439,7 @@ describe("Viewer state serialization", () => {
reg: "0:0.5,0:1,0:1",
slice: "0.25,0.75,0.5",
t: "100",
cam: "pos:-1.05%2C-4%2C45,tar:0%2C0%2C0,up:0%2C1%2C0,ort:3.534,fov:43.5",
};

describe("serializeViewerState", () => {
Expand All @@ -441,6 +450,18 @@ describe("Viewer state serialization", () => {
it("serializes custom viewer settings", () => {
expect(serializeViewerState(CUSTOM_VIEWER_STATE)).toEqual(SERIALIZED_CUSTOM_VIEWER_STATE);
});

it("deserializes partial camera settings", () => {
const state: Partial<ViewerState> = {
cameraState: {
position: [1.0, -1.4, 45],
up: [0, 1, 0],
fov: 43.5,
},
};
const serializedState = "pos:1%2C-1.4%2C45,up:0%2C1%2C0,fov:43.5";
expect(deserializeViewerState({ cam: serializedState })).toEqual(state);
});
});

describe("deserializeViewerState", () => {
Expand Down
79 changes: 73 additions & 6 deletions website/utils/url_utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import FirebaseRequest, { DatasetMetaData } from "../../public/firebase";
import { ControlPoint } from "@aics/volume-viewer";
import { CameraState, ControlPoint } from "@aics/volume-viewer";

import type {
ChannelState,
Expand Down Expand Up @@ -58,6 +58,20 @@ export enum ViewerStateKeys {
Region = "reg",
Slice = "slice",
Time = "t",
CameraState = "cam",
}

export enum CameraTransformKeys {
/** Camera position in 3D coordinates. */
Position = "pos",
/** Target position of the trackball controls in 3D coordinates. */
Target = "tar",
/** The up vector of the camera. Will be normalized to magnitude of 1. */
Up = "up",
/** Scale factor for orthographic cameras. */
OrthoScale = "ort",
/** Vertical FOV of the camera view frustum, from top to bottom, in degrees. */
Fov = "fov",
}

/**
Expand Down Expand Up @@ -190,6 +204,19 @@ export class ViewerStateParams {
[ViewerStateKeys.Slice]?: string = undefined;
/** Frame number, for time-series volumes. 0 by default. */
[ViewerStateKeys.Time]?: string = undefined;
/**
* Camera transform settings, as a list of `key:value` pairs separated by commas.
* Valid keys are defined in `CameraTransformKeys`:
* - `pos`: position
* - `tar`: target
* - `up`: up
* - `rot`: rotation
* - `ort`: orthographic scales
*
* All values are an array of three floats, separated by commas and
* encoded using `encodeURIComponent`.
*/
[ViewerStateKeys.CameraState]?: string = undefined;
}

/** URL parameters that define data sources when loading volumes. */
Expand Down Expand Up @@ -296,10 +323,14 @@ export function parseKeyValueList(data: string): Record<string, string> {
return result;
}

export function objectToKeyValueList(obj: Record<string, string>): string {
export function objectToKeyValueList(obj: Record<string, string | undefined>): string {
const keyValuePairs: string[] = [];
for (const key in obj) {
keyValuePairs.push(`${encodeURIComponent(key)}:${encodeURIComponent(obj[key].trim())}`);
const value = obj[key];
if (value === undefined) {
continue;
}
keyValuePairs.push(`${encodeURIComponent(key)}:${encodeURIComponent(value.trim())}`);
}
return keyValuePairs.join(",");
}
Expand Down Expand Up @@ -409,11 +440,18 @@ function parseStringSlice(region: string | undefined): PerAxis<number> | undefin
return { x, y, z };
}

function parseStringLevels(levels: string | undefined): [number, number, number] | undefined {
/**
* Parses an array of three numbers from a string.
*/
function parseThreeNumberArray(
levels: string | undefined,
min: number = -Infinity,
max: number = Infinity
): [number, number, number] | undefined {
if (!levels) {
return undefined;
}
const [low, middle, high] = levels.split(",").map((val) => parseStringFloat(val, 0, 255));
const [low, middle, high] = levels.split(",").map((val) => parseStringFloat(val, min, max));
if (low === undefined || middle === undefined || high === undefined) {
return undefined;
}
Expand All @@ -440,6 +478,32 @@ function parseStringRegion(region: string | undefined): PerAxis<[number, number]
return { x, y, z };
}

function parseCameraState(cameraSettings: string | undefined): Partial<CameraState> | undefined {
if (!cameraSettings) {
return undefined;
}
const parsedCameraSettings = parseKeyValueList(cameraSettings);
const result: Partial<CameraState> = {
position: parseThreeNumberArray(parsedCameraSettings[CameraTransformKeys.Position]),
target: parseThreeNumberArray(parsedCameraSettings[CameraTransformKeys.Target]),
up: parseThreeNumberArray(parsedCameraSettings[CameraTransformKeys.Up]),
// Orthographic scales cannot be negative
orthoScale: parseStringFloat(parsedCameraSettings[CameraTransformKeys.OrthoScale], 0, Infinity),
fov: parseStringFloat(parsedCameraSettings[CameraTransformKeys.Fov], 0, 180),
};
return removeUndefinedProperties(result);
}

function serializeCameraState(cameraState: CameraState): string {
return objectToKeyValueList({
[CameraTransformKeys.Position]: cameraState.position.join(","),
[CameraTransformKeys.Target]: cameraState.target.join(","),
[CameraTransformKeys.Up]: cameraState.up.join(","),
[CameraTransformKeys.OrthoScale]: cameraState.orthoScale?.toString(),
[CameraTransformKeys.Fov]: cameraState.fov?.toString(),
});
}

function serializeControlPoints(controlPoints: ControlPoint[]): string {
return controlPoints.map((cp) => `${cp.x}:${cp.opacity}:${colorArrayToHex(cp.color)}`).join(",");
}
Expand Down Expand Up @@ -528,12 +592,13 @@ export function deserializeViewerState(params: ViewerStateParams): Partial<Viewe
autorotate: parseStringBoolean(params[ViewerStateKeys.Autorotate]),
brightness: parseStringFloat(params[ViewerStateKeys.Brightness], 0, 100),
density: parseStringFloat(params[ViewerStateKeys.Density], 0, 100),
levels: parseStringLevels(params[ViewerStateKeys.Levels]),
levels: parseThreeNumberArray(params[ViewerStateKeys.Levels], 0, 255),
interpolationEnabled: parseStringBoolean(params[ViewerStateKeys.Interpolation]),
region: parseStringRegion(params[ViewerStateKeys.Region]),
slice: parseStringSlice(params[ViewerStateKeys.Slice]),
time: parseStringInt(params[ViewerStateKeys.Time], 0, Number.POSITIVE_INFINITY),
renderMode: parseStringEnum(params[ViewerStateKeys.Mode], RenderMode),
cameraState: parseCameraState(params[ViewerStateKeys.CameraState]),
};

// Handle viewmode, since they use different mappings
Expand Down Expand Up @@ -577,6 +642,8 @@ export function serializeViewerState(state: Partial<ViewerState>): ViewerStatePa
[ViewerStateKeys.Slice]: state.slice && `${state.slice.x},${state.slice.y},${state.slice.z}`,
[ViewerStateKeys.Levels]: state.levels?.join(","),
[ViewerStateKeys.Time]: state.time?.toString(),
// All CameraTransform properties will be provided when serializing viewer state
[ViewerStateKeys.CameraState]: state.cameraState && serializeCameraState(state.cameraState as CameraState),
};
const viewModeToViewParam = {
[ViewMode.threeD]: "3D",
Expand Down

0 comments on commit 8f14ffd

Please sign in to comment.