diff --git a/docs/ui/keyboard_shortcuts.md b/docs/ui/keyboard_shortcuts.md index df83924e80d..9510a3792ed 100644 --- a/docs/ui/keyboard_shortcuts.md +++ b/docs/ui/keyboard_shortcuts.md @@ -40,27 +40,28 @@ A complete listing of all available keyboard & mouse shortcuts for WEBKNOSSOS ca Note that skeleton-specific mouse actions are usually only available when the skeleton tool is active. -| Key Binding | Operation | -| --------------------------------------------- | ------------------------------------------- | -| Left Mouse Drag or Arrow Keys | Move In-Plane | -| ++alt++ + Mouse Move | Move In-Plane | -| ++space++ | Move Forward | -| Scroll Mousewheel (3D View) | Zoom In And Out | -| Right-Click Drag (3D View) | Rotate 3D View | -| Left Click | Create New Node | -| Left Click | Select Node (Mark as Active Node) under cursor | -| Left Drag | Move node under cursor | -| Right Click (on node) | Bring up the context-menu with further actions | -| ++shift++ + ++alt++ + Left Click | Merge Two Nodes and Combine Trees | -| ++shift++ + ++ctrl++ / ++cmd++ + Left Click | Delete Edge / Split Trees | -| ++c++ | Create New Tree | -| ++ctrl++ / ++cmd++ + ++period++ | Navigate to the next Node (Mark as Active) | -| ++ctrl++ / ++cmd++ + ++comma++ | Navigate to previous Node (Mark as Active) | -| ++ctrl++ / ++cmd++ + Left Click or ++ctrl++ / ++cmd++ + Arrow Keys | Move the Active Node | -| ++del++ | Delete Node / Split Trees | -| ++b++ | Mark Node as New Branchpoint | -| ++j++ | Jump To Last Branchpoint | -| ++s++ | Center Camera on Active Node | +| Key Binding | Operation | +| -------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- | +| Left Mouse Drag or Arrow Keys | Move In-Plane | +| ++alt++ + Mouse Move | Move In-Plane | +| ++space++ | Move Forward | +| ++shift++ + ++up++ / ++down++ / ++left++ / ++right++
++alt++ + ++left++ / ++right++ | Rotate Planes | +| ++ctrl++ / ++cmd++ + ++shift++ + ++up++ / ++down++ / ++left++ / ++right++
++ctrl++ / ++cmd++ + ++alt++ + ++left++ / ++right++ | Rotate Planes by 90° | +| Right-Click Drag (3D View) | Rotate 3D View | +| Left Click | Create New Node | +| Left Click | Select Node (Mark as Active Node) under cursor | +| Left Drag | Move node under cursor | +| Right Click (on node) | Bring up the context-menu with further actions | +| ++shift++ + ++alt++ + Left Click | Merge Two Nodes and Combine Trees | +| ++shift++ + ++ctrl++ / ++cmd++ + Left Click | Delete Edge / Split Trees | +| ++c++ | Create New Tree | +| ++ctrl++ / ++cmd++ + ++period++ | Navigate to the next Node (Mark as Active) | +| ++ctrl++ / ++cmd++ + ++comma++ | Navigate to previous Node (Mark as Active) | +| ++ctrl++ / ++cmd++ + Left Click or ++ctrl++ / ++cmd++ + Arrow Keys | Move the Active Node | +| ++del++ | Delete Node / Split Trees | +| ++b++ | Mark Node as New Branchpoint | +| ++j++ | Jump To Last Branchpoint | +| ++s++ | Center Camera on Active Node | Note that you can enable *Classic Controls* which will behave slightly different and more explicit for the mouse actions: diff --git a/frontend/javascripts/libs/mjs.ts b/frontend/javascripts/libs/mjs.ts index 8c6b42dcc96..16adb115cee 100644 --- a/frontend/javascripts/libs/mjs.ts +++ b/frontend/javascripts/libs/mjs.ts @@ -387,6 +387,8 @@ const V3 = { prod(a: Vector3) { return a[0] * a[1] * a[2]; }, + + multiply: scale3, }; const V4 = { diff --git a/frontend/javascripts/messages.tsx b/frontend/javascripts/messages.tsx index 40494980096..52e7c526fc8 100644 --- a/frontend/javascripts/messages.tsx +++ b/frontend/javascripts/messages.tsx @@ -22,6 +22,7 @@ export const settings: Partial> = moveValue: "Move Value (nm/s)", newNodeNewTree: "Single-node-tree mode (Soma clicking)", centerNewNode: "Auto-center Nodes", + applyNodeRotationOnActivation: "Auto-rotate to Nodes", highlightCommentedNodes: "Highlight Commented Nodes", overrideNodeRadius: "Override Node Radius", particleSize: "Particle Size", @@ -159,7 +160,6 @@ instead. Only enable this option if you understand its effect. All layers will n "The current position is outside of the dataset's bounding box. No data will be shown here.", "tracing.out_of_task_bounds": "The current position is outside of the task's bounding box.", "tracing.copy_position": "Copy position to clipboard", - "tracing.copy_rotation": "Copy rotation to clipboard", "tracing.copy_sharing_link": "Copy sharing link to clipboard", "tracing.tree_length_notification": (treeName: string, lengthInNm: string, lengthInVx: string) => `The tree ${treeName} has a total path length of ${lengthInNm} (${lengthInVx}).`, diff --git a/frontend/javascripts/test/api/api_skeleton_latest.spec.ts b/frontend/javascripts/test/api/api_skeleton_latest.spec.ts index 8750278301b..1b2e6707c93 100644 --- a/frontend/javascripts/test/api/api_skeleton_latest.spec.ts +++ b/frontend/javascripts/test/api/api_skeleton_latest.spec.ts @@ -5,8 +5,42 @@ import { setTreeGroupsAction } from "viewer/model/actions/skeletontracing_action import { userSettings } from "types/schemas/user_settings.schema"; import Store from "viewer/store"; import { vi, describe, it, expect, beforeEach } from "vitest"; -import type { Vector3 } from "viewer/constants"; +import { + OrthoBaseRotations, + OrthoViewToNumber, + OrthoViewValuesWithoutTDView, + type Vector3, +} from "viewer/constants"; import { enforceSkeletonTracing } from "viewer/model/accessors/skeletontracing_accessor"; +import { setViewportAction } from "viewer/model/actions/view_mode_actions"; +import { setRotationAction } from "viewer/model/actions/flycam_actions"; +import * as THREE from "three"; +import { + eulerAngleToReducerInternalMatrix, + reducerInternalMatrixToEulerAngle, +} from "viewer/model/helpers/rotation_helpers"; +import testRotations from "test/fixtures/test_rotations"; +import { map3 } from "libs/utils"; + +const toRadian = (arr: Vector3): Vector3 => [ + THREE.MathUtils.degToRad(arr[0]), + THREE.MathUtils.degToRad(arr[1]), + THREE.MathUtils.degToRad(arr[2]), +]; + +function applyRotationInFlycamReducerSpace( + flycamRotationInRadian: Vector3, + rotationToApply: THREE.Euler, +): Vector3 { + // eulerAngleToReducerInternalMatrix and reducerInternalMatrixToEulerAngle are tested in rotation_helpers.spec.ts. + // Calculate expected rotation and make it a quaternion for equal comparison. + const rotationMatrix = eulerAngleToReducerInternalMatrix(flycamRotationInRadian); + const rotationMatrixWithViewport = rotationMatrix.multiply( + new THREE.Matrix4().makeRotationFromEuler(rotationToApply), + ); + const resultingAngle = reducerInternalMatrixToEulerAngle(rotationMatrixWithViewport); + return resultingAngle; +} describe("API Skeleton", () => { beforeEach(async (context) => { @@ -298,4 +332,76 @@ describe("API Skeleton", () => { true, ); }); + + it("should create skeleton nodes with correct properties.", ({ api }) => { + const flycamRotation = [0, 0, 0] as Vector3; + for (const planeId of OrthoViewValuesWithoutTDView) { + const rotationForComparison = applyRotationInFlycamReducerSpace( + flycamRotation, + OrthoBaseRotations[planeId], + ); + const rotationQuaternion = new THREE.Quaternion().setFromEuler( + new THREE.Euler(...rotationForComparison), + ); + Store.dispatch(setRotationAction(flycamRotation)); + Store.dispatch(setViewportAction(planeId)); + api.tracing.createNode([10, 10, 10], { activate: true }); + const skeletonTracing = enforceSkeletonTracing(Store.getState().annotation); + // Throw error if no node / tree is active by passing -1 as id. + const newNode = skeletonTracing.trees + .getOrThrow(skeletonTracing.activeTreeId || -1) + .nodes.getOrThrow(skeletonTracing.activeNodeId || -1); + const propsToCheck = { + untransformedPosition: newNode.untransformedPosition, + additionalCoordinates: newNode.additionalCoordinates, + viewport: newNode.viewport, + mag: newNode.mag, + }; + expect(propsToCheck).toStrictEqual({ + untransformedPosition: [10, 10, 10], + additionalCoordinates: [], + viewport: OrthoViewToNumber[planeId], + mag: 0, + }); + const newNodeQuaternion = new THREE.Quaternion().setFromEuler( + new THREE.Euler(...toRadian(newNode.rotation)), + ); + expect( + rotationQuaternion.angleTo(newNodeQuaternion), + `Node rotation ${newNode.rotation} is not nearly equal to ${map3(THREE.MathUtils.radToDeg, rotationForComparison)} in viewport ${planeId}.`, + ).toBeLessThan(0.000001); + } + }); + it("should create skeleton nodes with correct rotation when flycam is rotated in all three viewports.", ({ + api, + }) => { + for (const testRotation of testRotations) { + for (const planeId of OrthoViewValuesWithoutTDView) { + const rotationInRadian = toRadian(testRotation); + const resultingAngle = applyRotationInFlycamReducerSpace( + rotationInRadian, + OrthoBaseRotations[planeId], + ); + const rotationQuaternion = new THREE.Quaternion().setFromEuler( + new THREE.Euler(...resultingAngle), + ); + // Test node creation. + Store.dispatch(setRotationAction(testRotation)); + Store.dispatch(setViewportAction(planeId)); + api.tracing.createNode([10, 10, 10], { activate: true }); + const skeletonTracing = enforceSkeletonTracing(Store.getState().annotation); + // Throw error if no node / tree is active by passing -1 as id. + const newNode = skeletonTracing.trees + .getOrThrow(skeletonTracing.activeTreeId || -1) + .nodes.getOrThrow(skeletonTracing.activeNodeId || -1); + const newNodeQuaternion = new THREE.Quaternion().setFromEuler( + new THREE.Euler(...toRadian(newNode.rotation)), + ); + expect( + rotationQuaternion.angleTo(newNodeQuaternion), + `Node rotation ${newNode.rotation} is not nearly equal to ${map3(THREE.MathUtils.radToDeg, resultingAngle)} in viewport ${planeId}.`, + ).toBeLessThan(0.000001); + } + } + }); }); diff --git a/frontend/javascripts/test/controller/url_manager.spec.ts b/frontend/javascripts/test/controller/url_manager.spec.ts index 8fc3696d7f4..e151eaec1aa 100644 --- a/frontend/javascripts/test/controller/url_manager.spec.ts +++ b/frontend/javascripts/test/controller/url_manager.spec.ts @@ -12,6 +12,7 @@ import defaultState from "viewer/default_state"; import update from "immutability-helper"; import DATASET from "../fixtures/dataset_server_object"; import _ from "lodash"; +import { FlycamMatrixWithDefaultRotation } from "test/fixtures/flycam_object"; describe("UrlManager", () => { it("should replace tracing in url", () => { @@ -192,7 +193,7 @@ describe("UrlManager", () => { additionalCoordinates: [], mode, zoomStep: 1.3, - rotation: [0, 0, 180] as Vector3 as Vector3, + rotation: [0, 0, 180] as Vector3, }; const initialState = update(defaultState, { temporaryConfiguration: { @@ -206,10 +207,62 @@ describe("UrlManager", () => { expect(UrlManager.parseUrlHash()).toEqual(urlState as Partial); }); + it("should support hashes with active node id and without a rotation", () => { + location.hash = "#3705,5200,795,0,1.3,15"; + const urlState = UrlManager.parseUrlHash(); + expect(urlState).toStrictEqual({ + position: [3705, 5200, 795], + mode: "orthogonal", + zoomStep: 1.3, + activeNode: 15, + }); + }); + + it("should parse an empty rotation", () => { + location.hash = "#3584,3584,1024,0,2,0,0,0"; + const urlState = UrlManager.parseUrlHash(); + expect(urlState).toStrictEqual({ + position: [3584, 3584, 1024], + mode: "orthogonal", + zoomStep: 2, + rotation: [0, 0, 0], + }); + }); + + it("should parse a rotation and active node id correctly", () => { + location.hash = "#3334,3235,999,0,2,282,308,308,11"; + const urlState = UrlManager.parseUrlHash(); + expect(urlState).toStrictEqual({ + position: [3334, 3235, 999], + mode: "orthogonal", + zoomStep: 2, + rotation: [282, 308, 308], + activeNode: 11, + }); + }); + it("should build default url in csv format", () => { UrlManager.initialize(); const url = UrlManager.buildUrl(); - expect(url).toBe("#0,0,0,0,1.3"); + // The default state in the store does not include the rotation of 180 degrees around z axis which is always subtracted from the rotation. + // Thus, the rotation of 180 around z is present. + expect(url).toBe("#0,0,0,0,1.3,0,0,180"); + }); + + it("should build csv url hash without rotation if it is [0,0,0]", () => { + const rotationMatrixWithDefaultRotation = FlycamMatrixWithDefaultRotation; + const initialState = update(defaultState, { + flycam: { + currentMatrix: { + $set: rotationMatrixWithDefaultRotation, + }, + rotation: { + $set: [0, 0, 0], + }, + }, + }); + const hash = `#${UrlManager.buildUrlHashCsv(initialState)}`; + expect(hash).toBe("#0,0,0,0,1.3"); }); it("The dataset name should be correctly extracted from view URLs", () => { diff --git a/frontend/javascripts/test/fixtures/flycam_object.ts b/frontend/javascripts/test/fixtures/flycam_object.ts new file mode 100644 index 00000000000..1310255e0fd --- /dev/null +++ b/frontend/javascripts/test/fixtures/flycam_object.ts @@ -0,0 +1,5 @@ +import { M4x4 } from "libs/mjs"; + +// Apply the default 180 z axis rotation to identity matrix as this is always applied on every flycam per default. +// This can be useful in tests to get a calculated rotation of [0, 0, 0]. Otherwise it would be [0, 0, 180]. +export const FlycamMatrixWithDefaultRotation = M4x4.rotate(Math.PI, [0, 0, 1], M4x4.identity(), []); diff --git a/frontend/javascripts/test/fixtures/test_rotations.ts b/frontend/javascripts/test/fixtures/test_rotations.ts new file mode 100644 index 00000000000..bb599f9efc6 --- /dev/null +++ b/frontend/javascripts/test/fixtures/test_rotations.ts @@ -0,0 +1,16 @@ +import type { Vector3 } from "viewer/constants"; + +const testRotations: Vector3[] = [ + [0, 0, 0], + [83, 0, 0], + [10, 10, 10], + [90, 160, 90], + [30, 90, 40], + [90, 90, 90], + [180, 180, 180], + [30, 30, 30], + [90, 90, 188], + [333, 222, 111], + [73, 400, 666], +]; +export default testRotations; diff --git a/frontend/javascripts/test/fixtures/volumetracing_object.ts b/frontend/javascripts/test/fixtures/volumetracing_object.ts index 1709c6432e0..cc8e031db93 100644 --- a/frontend/javascripts/test/fixtures/volumetracing_object.ts +++ b/frontend/javascripts/test/fixtures/volumetracing_object.ts @@ -1,6 +1,7 @@ import update from "immutability-helper"; import Constants from "viewer/constants"; import defaultState from "viewer/default_state"; +import { FlycamMatrixWithDefaultRotation } from "./flycam_object"; import { combinedReducer } from "viewer/store"; import { setDatasetAction } from "viewer/model/actions/dataset_actions"; import { convertFrontendBoundingBoxToServer } from "viewer/model/reducers/reducer_helpers"; @@ -110,6 +111,13 @@ const stateWithoutDatasetInitialization = update(defaultState, { }, }, }, + flycam: { + currentMatrix: { + // Apply the default 180 z axis rotation to get correct result in ortho related tests. + // This ensures the calculated flycam rotation is [0, 0, 0]. Otherwise it would be [0, 0, 180]. + $set: FlycamMatrixWithDefaultRotation, + }, + }, }); export const initialState = combinedReducer( diff --git a/frontend/javascripts/test/libs/transform_spec_helpers.ts b/frontend/javascripts/test/libs/transform_spec_helpers.ts index 318f85d9bbc..c0edf150df5 100644 --- a/frontend/javascripts/test/libs/transform_spec_helpers.ts +++ b/frontend/javascripts/test/libs/transform_spec_helpers.ts @@ -6,14 +6,15 @@ export function almostEqual( vec1: Vector3, vec2: Vector3, threshold: number = 1, + message?: string, ) { const diffX = Math.abs(vec1[0] - vec2[0]); const diffY = Math.abs(vec1[1] - vec2[1]); const diffZ = Math.abs(vec1[2] - vec2[2]); - expect(diffX).toBeLessThan(threshold); - expect(diffY).toBeLessThan(threshold); - expect(diffZ).toBeLessThan(threshold); + expect(diffX, message).toBeLessThan(threshold); + expect(diffY, message).toBeLessThan(threshold); + expect(diffZ, message).toBeLessThan(threshold); } export function getPointsC555() { diff --git a/frontend/javascripts/test/misc/rotation_helpers.spec.ts b/frontend/javascripts/test/misc/rotation_helpers.spec.ts new file mode 100644 index 00000000000..63337fc0e90 --- /dev/null +++ b/frontend/javascripts/test/misc/rotation_helpers.spec.ts @@ -0,0 +1,66 @@ +import { describe, it, expect } from "vitest"; +import { + eulerAngleToReducerInternalMatrix, + reducerInternalMatrixToEulerAngle, +} from "viewer/model/helpers/rotation_helpers"; +import type { Vector3 } from "viewer/constants"; +import * as THREE from "three"; +import { map3 } from "libs/utils"; +import testRotations from "test/fixtures/test_rotations"; + +describe("Rotation Helper Functions", () => { + it("should result in an equal rotation after transforming into flycam reducer rotation space and back.", () => { + for (const testRotation of testRotations) { + const inputRotationInRadian = map3(THREE.MathUtils.degToRad, testRotation); + const rotationMatrix = eulerAngleToReducerInternalMatrix(inputRotationInRadian); + const resultingAngle = reducerInternalMatrixToEulerAngle(rotationMatrix); + const inputQuaternion = new THREE.Quaternion().setFromEuler( + new THREE.Euler(...inputRotationInRadian), + ); + const outputQuaternion = new THREE.Quaternion().setFromEuler( + new THREE.Euler(...resultingAngle), + ); + expect( + inputQuaternion.angleTo(outputQuaternion), + `Angle ${testRotation} is not converted properly`, + ).toBeLessThan(0.000001); + } + }); + // This tests goal is to test and document that the output after converting back from the 'flycam rotation reducer space' the result needs to be interpreted as a XYZ Euler angle. + it("should *not* result in an equal rotation after transforming into flycam reducer rotation space and back if interpreted in wrong euler order.", () => { + const testRotation = [30, 90, 40] as Vector3; + const inputRotationInRadian = map3(THREE.MathUtils.degToRad, testRotation); + const rotationMatrix = eulerAngleToReducerInternalMatrix(inputRotationInRadian); + const resultingAngle = reducerInternalMatrixToEulerAngle(rotationMatrix); + const inputQuaternionZYX = new THREE.Quaternion().setFromEuler( + new THREE.Euler(...inputRotationInRadian, "ZYX"), + ); + const outputQuaternionZYX = new THREE.Quaternion().setFromEuler( + new THREE.Euler(...resultingAngle, "ZYX"), + ); + expect( + inputQuaternionZYX.angleTo(outputQuaternionZYX), + `Angle ${testRotation} is equal although interpreted as 'ZYX' euler order. `, + ).toBeGreaterThan(0.001); + const inputQuaternionYZX = new THREE.Quaternion().setFromEuler( + new THREE.Euler(...inputRotationInRadian, "YZX"), + ); + const outputQuaternionYZX = new THREE.Quaternion().setFromEuler( + new THREE.Euler(...resultingAngle, "YZX"), + ); + expect( + inputQuaternionYZX.angleTo(outputQuaternionYZX), + `Angle ${testRotation} is equal although interpreted as 'YZX' euler order. `, + ).toBeGreaterThan(0.001); + const inputQuaternionXYZ = new THREE.Quaternion().setFromEuler( + new THREE.Euler(...inputRotationInRadian, "XYZ"), + ); + const outputQuaternionXYZ = new THREE.Quaternion().setFromEuler( + new THREE.Euler(...resultingAngle, "XYZ"), + ); + expect( + inputQuaternionXYZ.angleTo(outputQuaternionXYZ), + `Angle ${testRotation} is not equal although interpreted as 'XYZ' euler order should be correct. `, + ).toBeLessThan(0.00001); + }); +}); diff --git a/frontend/javascripts/test/model/flycam_accessors.spec.ts b/frontend/javascripts/test/model/accessors/flycam_accessors.spec.ts similarity index 100% rename from frontend/javascripts/test/model/flycam_accessors.spec.ts rename to frontend/javascripts/test/model/accessors/flycam_accessors.spec.ts diff --git a/frontend/javascripts/test/model/accessors/view_mode_accessors.spec.ts b/frontend/javascripts/test/model/accessors/view_mode_accessors.spec.ts new file mode 100644 index 00000000000..0f5c983cc09 --- /dev/null +++ b/frontend/javascripts/test/model/accessors/view_mode_accessors.spec.ts @@ -0,0 +1,352 @@ +import update from "immutability-helper"; +import { describe, it, expect } from "vitest"; +import type { WebknossosState } from "viewer/store"; +import * as accessors from "viewer/model/accessors/view_mode_accessor"; +import { OrthoViews, OrthoViewValuesWithoutTDView, UnitLong, type Vector3 } from "viewer/constants"; +import defaultState from "viewer/default_state"; +import { FlycamMatrixWithDefaultRotation } from "test/fixtures/flycam_object"; +import { M4x4, V3 } from "libs/mjs"; +import Dimensions from "viewer/model/dimensions"; +import { setRotationAction } from "viewer/model/actions/flycam_actions"; +import FlycamReducer from "viewer/model/reducers/flycam_reducer"; +import * as THREE from "three"; +import { map3 } from "libs/utils"; +import { getBaseVoxelFactorsInUnit } from "viewer/model/scaleinfo"; +import { almostEqual } from "test/libs/transform_spec_helpers"; +import testRotations from "test/fixtures/test_rotations"; + +const viewportOffsets = { + left: 200, + top: 20, +}; +const viewportSize = 800; + +const viewModeDataInputCatcher = { + PLANE_XY: { + left: viewportOffsets.left, + top: viewportOffsets.top, + width: viewportSize, + height: viewportSize, + }, + PLANE_YZ: { + left: viewportOffsets.left + viewportSize, + top: viewportOffsets.top, + width: viewportSize, + height: viewportSize, + }, + PLANE_XZ: { + left: viewportOffsets.left, + top: viewportOffsets.top + viewportSize, + width: viewportSize, + height: viewportSize, + }, + TDView: { + left: viewportOffsets.left + viewportSize, + top: viewportOffsets.top + viewportSize, + width: viewportSize, + height: viewportSize, + }, +}; + +const initialFlycamPosition = [100, 100, 100] as Vector3; + +const initialState: WebknossosState = { + ...defaultState, + dataset: { + ...defaultState.dataset, + dataSource: { + ...defaultState.dataset.dataSource, + scale: { factor: [1, 1, 1], unit: UnitLong.nm }, + }, + }, + datasetConfiguration: { ...defaultState.datasetConfiguration }, + userConfiguration: { + ...defaultState.userConfiguration, + sphericalCapRadius: 100, + dynamicSpaceDirection: true, + }, + flycam: { + ...defaultState.flycam, + zoomStep: 1.0, + // Flycam per default translated by initialFlycamPosition + // and has the default rotation of 180°. For more see flycam_reducer.ts + currentMatrix: M4x4.mul( + M4x4.translate(initialFlycamPosition, M4x4.identity()), + FlycamMatrixWithDefaultRotation, + ), + spaceDirectionOrtho: [1, 1, 1], + rotation: [0, 0, 0], + }, + viewModeData: { + ...defaultState.viewModeData, + plane: { + ...defaultState.viewModeData.plane, + activeViewport: OrthoViews.PLANE_XY, + inputCatcherRects: viewModeDataInputCatcher, + }, + }, +}; + +const testOffsets = [ + [-50, 0], + [50, 0], + [0, -50], + [0, 50], + [-50, -50], + [50, 50], + [-50, 50], + [50, -50], + [-122, -200], + [-33, -88], + [33, 88], + [123, 21], + [-322, -400], + [400, 322], +]; + +describe("View mode accessors", () => { + it("should calculate the correct global position at the center of the viewports.", () => { + for (const planeId of OrthoViewValuesWithoutTDView) { + const stateWithCorrectPlaneActive = update(initialState, { + viewModeData: { plane: { activeViewport: { $set: planeId } } }, + }); + const clickPositionAtViewportCenter = { x: viewportSize / 2, y: viewportSize / 2 }; + const globalPosition = accessors.calculateGlobalPos( + stateWithCorrectPlaneActive, + clickPositionAtViewportCenter, + ); + expect(globalPosition.rounded).toStrictEqual(initialFlycamPosition); + } + }); + it("should calculate the correct global position with certain offset from the viewport centers.", () => { + for (const offset of testOffsets) { + for (const planeId of OrthoViewValuesWithoutTDView) { + const stateWithCorrectPlaneActive = update(initialState, { + viewModeData: { plane: { activeViewport: { $set: planeId } } }, + }); + const clickPositionAtViewportCenter = { + x: viewportSize / 2 + offset[0], + y: viewportSize / 2 + offset[1], + }; + const globalPosition = accessors.calculateGlobalPos( + stateWithCorrectPlaneActive, + clickPositionAtViewportCenter, + ); + const viewportAdjustedOffset = Dimensions.transDim([offset[0], offset[1], 0], planeId); + const expectedPosition = [...V3.add(initialFlycamPosition, viewportAdjustedOffset)]; + expect( + globalPosition.rounded, + `Global position is wrong with offset ${offset} in viewport ${planeId}.`, + ).toStrictEqual(expectedPosition); + } + } + }); + it("should calculate the correct global position when the flycam is rotated around the x axis by 90 degrees.", () => { + for (const offset of testOffsets) { + for (const planeId of OrthoViewValuesWithoutTDView) { + const stateWithCorrectPlaneActive = update(initialState, { + viewModeData: { plane: { activeViewport: { $set: planeId } } }, + }); + const rotateAction = setRotationAction([90, 0, 0]); + const rotatedState = FlycamReducer(stateWithCorrectPlaneActive, rotateAction); + const clickPositionAtViewportCenter = { + x: viewportSize / 2 + offset[0], + y: viewportSize / 2 + offset[1], + }; + const globalPosition = accessors.calculateGlobalPos( + rotatedState, + clickPositionAtViewportCenter, + ); + const viewportAdjustedOffset = Dimensions.transDim([offset[0], offset[1], 0], planeId); + // Rotation of 90° around the x axis swaps y and z value and inverts the y value. + const rotatedAdjustedOffset = [ + viewportAdjustedOffset[0], + -viewportAdjustedOffset[2], + viewportAdjustedOffset[1], + ] as Vector3; + const expectedPosition = [...V3.add(initialFlycamPosition, rotatedAdjustedOffset)]; + expect( + globalPosition.rounded, + `Global position is wrong with offset ${offset} in viewport ${planeId}.`, + ).toStrictEqual(expectedPosition); + } + } + }); + it("should calculate the correct global position when the flycam is rotated with an evenly scaled dataset.", () => { + for (const rotation of testRotations) { + // When using the rotation of the flycam for calculations, one has to invert the z value and interpret the resulting euler angle as ZYX. + // More info about this at https://www.notion.so/scalableminds/3D-Rotations-3D-Scene-210b51644c6380c2a4a6f5f3c069738a?source=copy_link#22bb51644c6380138fdac454d4dac2f0. + const rotationCorrected = [rotation[0], rotation[1], -rotation[2]] as Vector3; + const rotationInRadian = map3(THREE.MathUtils.degToRad, rotationCorrected); + for (const offset of testOffsets) { + const stateWithCorrectPlaneActive = update(initialState, { + viewModeData: { plane: { activeViewport: { $set: OrthoViews.PLANE_XY } } }, + }); + const rotateAction = setRotationAction(rotation); + const rotatedState = FlycamReducer(stateWithCorrectPlaneActive, rotateAction); + const clickPositionAtViewportCenter = { + x: viewportSize / 2 + offset[0], + y: viewportSize / 2 + offset[1], + }; + const globalPosition = accessors.calculateGlobalPos( + rotatedState, + clickPositionAtViewportCenter, + ); + // Applying the rotation of 83° around the x axis to the offset. + const rotatedOffset = new THREE.Vector3(offset[0], offset[1], 0) + .applyEuler(new THREE.Euler(...rotationInRadian, "ZYX")) + .toArray(); + const expectedPosition = [...V3.add(initialFlycamPosition, rotatedOffset)] as Vector3; + almostEqual( + expect, + globalPosition.floating, + expectedPosition, + 2, + `Global position is wrong with offset ${offset} and rotation ${rotation} in viewport ${OrthoViews.PLANE_XY}. Expected ${globalPosition.floating} to nearly equal ${expectedPosition}.`, + ); + } + } + }); + it("should calculate the correct global position when the flycam is rotated and the dataset has an anisotropic scale.", () => { + const anisotropicDatasetScale = [11.239999771118164, 11.239999771118164, 28] as Vector3; + for (const rotation of testRotations) { + // When using the rotation of the flycam for calculations, one has to invert the z value and interpret the resulting euler angle as ZYX. + // More info about this at https://www.notion.so/scalableminds/3D-Rotations-3D-Scene-210b51644c6380c2a4a6f5f3c069738a?source=copy_link#22bb51644c6380138fdac454d4dac2f0. + const rotationCorrected = [rotation[0], rotation[1], -rotation[2]] as Vector3; + const rotationInRadian = map3(THREE.MathUtils.degToRad, rotationCorrected); + for (const offset of testOffsets) { + const stateWithAnisotropicScale = update(initialState, { + dataset: { dataSource: { scale: { factor: { $set: anisotropicDatasetScale } } } }, + }); + const rotateAction = setRotationAction(rotation); + const rotatedState = FlycamReducer(stateWithAnisotropicScale, rotateAction); + const clickPositionAtViewportCenter = { + x: viewportSize / 2 + offset[0], + y: viewportSize / 2 + offset[1], + }; + const globalPosition = accessors.calculateGlobalPos( + rotatedState, + clickPositionAtViewportCenter, + ); + // Applying the rotation of 83° around the x axis to the offset and the apply the dataset scale. + const scaleFactor = getBaseVoxelFactorsInUnit(rotatedState.dataset.dataSource.scale); + const rotatedAndScaledOffset = new THREE.Vector3(offset[0], offset[1], 0) + .applyEuler(new THREE.Euler(...rotationInRadian, "ZYX")) + .multiply(new THREE.Vector3(...scaleFactor)) + .toArray(); + const expectedPosition = [ + ...V3.add(initialFlycamPosition, V3.round(rotatedAndScaledOffset)), + ] as Vector3; + console.error( + "rotationInRadian", + rotationInRadian, + "rotatedAndScaledOffset", + rotatedAndScaledOffset, + ); + almostEqual( + expect, + globalPosition.rounded, + expectedPosition, + 2, + `Global position is wrong with offset ${offset} and rotation ${rotation} in viewport ${OrthoViews.PLANE_XY}. Expected ${globalPosition.rounded} to nearly equal ${expectedPosition}.`, + ); + } + } + }); + it("should calculate the correct global position and correctly calculate it back to viewports perceptive position when flycam is rotated and with a dataset with an even scale.", () => { + const anisotropicDatasetScale = [1, 1, 1] as Vector3; + for (const rotation of testRotations) { + // When using the rotation of the flycam for calculations, one has to invert the z value and interpret the resulting euler angle as ZYX. + // More info about this at https://www.notion.so/scalableminds/3D-Rotations-3D-Scene-210b51644c6380c2a4a6f5f3c069738a?source=copy_link#22bb51644c6380138fdac454d4dac2f0. + const rotationCorrected = [rotation[0], rotation[1], -rotation[2]] as Vector3; + const rotationInRadian = map3(THREE.MathUtils.degToRad, rotationCorrected); + for (const offset of testOffsets) { + for (const planeId of OrthoViewValuesWithoutTDView) { + const stateWithAnisotropicScale = update(initialState, { + dataset: { dataSource: { scale: { factor: { $set: anisotropicDatasetScale } } } }, + viewModeData: { plane: { activeViewport: { $set: planeId } } }, + }); + const rotateAction = setRotationAction(rotation); + const rotatedState = FlycamReducer(stateWithAnisotropicScale, rotateAction); + const clickPositionAtViewportCenter = { + x: viewportSize / 2 + offset[0], + y: viewportSize / 2 + offset[1], + }; + const globalPosition = accessors.calculateGlobalPos( + rotatedState, + clickPositionAtViewportCenter, + ); + // Applying the rotation of 83° around the x axis to the offset and the apply the dataset scale. + const scaleFactor = getBaseVoxelFactorsInUnit(rotatedState.dataset.dataSource.scale); + const posInViewportVector = accessors.calculateInViewportPos( + globalPosition.floating, + initialFlycamPosition, + rotationInRadian, + scaleFactor, + rotatedState.flycam.zoomStep, + ); + const posInViewport = Dimensions.transDim( + [posInViewportVector.x, posInViewportVector.y, posInViewportVector.z], + planeId, + ); + const expected3DOffset = [...offset, 0] as Vector3; + almostEqual( + expect, + posInViewport, + expected3DOffset, + 2, + `Global position is wrong with offset ${offset} and rotation ${rotation} in viewport ${OrthoViews.PLANE_XY}. Expected ${posInViewport} to nearly equal ${expected3DOffset}.`, + ); + } + } + } + }); + it("should calculate the correct global position and correctly calculate it back to viewports perceptive position when flycam is rotated and with a dataset with an anisotropic scale.", () => { + const anisotropicDatasetScale = [11.239999771118164, 11.239999771118164, 28] as Vector3; + for (const rotation of testRotations) { + // When using the rotation of the flycam for calculations, one has to invert the z value and interpret the resulting euler angle as ZYX. + // More info about this at https://www.notion.so/scalableminds/3D-Rotations-3D-Scene-210b51644c6380c2a4a6f5f3c069738a?source=copy_link#22bb51644c6380138fdac454d4dac2f0. + const rotationCorrected = [rotation[0], rotation[1], -rotation[2]] as Vector3; + const rotationInRadian = map3(THREE.MathUtils.degToRad, rotationCorrected); + for (const offset of testOffsets) { + for (const planeId of OrthoViewValuesWithoutTDView) { + const stateWithAnisotropicScale = update(initialState, { + dataset: { dataSource: { scale: { factor: { $set: anisotropicDatasetScale } } } }, + viewModeData: { plane: { activeViewport: { $set: planeId } } }, + }); + const rotateAction = setRotationAction(rotation); + const rotatedState = FlycamReducer(stateWithAnisotropicScale, rotateAction); + const clickPositionAtViewportCenter = { + x: viewportSize / 2 + offset[0], + y: viewportSize / 2 + offset[1], + }; + const globalPosition = accessors.calculateGlobalPos( + rotatedState, + clickPositionAtViewportCenter, + ); + // Applying the rotation of 83° around the x axis to the offset and the apply the dataset scale. + const scaleFactor = getBaseVoxelFactorsInUnit(rotatedState.dataset.dataSource.scale); + const posInViewportVector = accessors.calculateInViewportPos( + globalPosition.floating, + initialFlycamPosition, + rotationInRadian, + scaleFactor, + rotatedState.flycam.zoomStep, + ); + const posInViewport = Dimensions.transDim( + [posInViewportVector.x, posInViewportVector.y, posInViewportVector.z], + planeId, + ); + const expected3DOffset = [...offset, 0] as Vector3; + almostEqual( + expect, + posInViewport, + expected3DOffset, + 2, + `Global position is wrong with offset ${offset} and rotation ${rotation} in viewport ${OrthoViews.PLANE_XY}. Expected ${posInViewport} to nearly equal ${expected3DOffset}.`, + ); + } + } + } + }); +}); diff --git a/frontend/javascripts/test/reducers/flycam_reducer.spec.ts b/frontend/javascripts/test/reducers/flycam_reducer.spec.ts index 355e47e5bc3..00ccc9890eb 100644 --- a/frontend/javascripts/test/reducers/flycam_reducer.spec.ts +++ b/frontend/javascripts/test/reducers/flycam_reducer.spec.ts @@ -4,13 +4,14 @@ import { UnitLong, OrthoViews } from "viewer/constants"; import update from "immutability-helper"; import { getPosition, - getRotation, + getRotationInDegrees, getUp, getLeft, getZoomedMatrix, } from "viewer/model/accessors/flycam_accessor"; import * as FlycamActions from "viewer/model/actions/flycam_actions"; import FlycamReducer from "viewer/model/reducers/flycam_reducer"; +import { FlycamMatrixWithDefaultRotation } from "test/fixtures/flycam_object"; import { describe, it, expect } from "vitest"; function equalWithEpsilon(a: number[], b: number[], epsilon = 1e-10) { @@ -36,7 +37,9 @@ const initialState = { flycam: { zoomStep: 2, additionalCoordinates: [], - currentMatrix: M4x4.identity(), + // Apply the default 180 z axis rotation to get correct result in ortho related tests. + // This makes the calculated flycam rotation to [0, 0, 0]. Otherwise it would be [0, 0, 180]. + currentMatrix: FlycamMatrixWithDefaultRotation, spaceDirectionOrtho: [1, 1, 1], }, temporaryConfiguration: { @@ -45,8 +48,12 @@ const initialState = { }; describe("Flycam", () => { + // Removing the default rotation from the matrix to have an easy expected matrix. Else the scaled rotation matrix would be harder to test. + const stateWithoutDefaultFlycamRotation = update(initialState, { + flycam: { currentMatrix: { $set: M4x4.identity() } }, + }); it("should calculate zoomed matrix", () => { - expect(Array.from(getZoomedMatrix(initialState.flycam))).toEqual([ + expect(Array.from(getZoomedMatrix(stateWithoutDefaultFlycamRotation.flycam))).toEqual([ 2, 0, 0, 0, 0, 2, 0, 0, 0, 0, 2, 0, 0, 0, 0, 1, ]); }); @@ -54,26 +61,43 @@ describe("Flycam", () => { it("should move the flycam", () => { const moveAction = FlycamActions.moveFlycamAction([1, 2, 3]); const newState = FlycamReducer(initialState, moveAction); - equalWithEpsilon(getPosition(newState.flycam), [1, 2, 3]); + // Due to initial rotation of 180 degree around z axis, x and y values are inverted. + equalWithEpsilon(getPosition(newState.flycam), [-1, -2, 3]); }); it("should move the flycam backwards", () => { const moveAction = FlycamActions.moveFlycamAction([-1, -2, -3]); const newState = FlycamReducer(initialState, moveAction); - equalWithEpsilon(getPosition(newState.flycam), [-1, -2, -3]); + // Due to initial rotation of 180 degree around z axis, x and y values are inverted. + equalWithEpsilon(getPosition(newState.flycam), [1, 2, -3]); }); it("should move the flycam and move it again", () => { const moveAction = FlycamActions.moveFlycamAction([1, 2, 3]); let newState = FlycamReducer(initialState, moveAction); newState = FlycamReducer(newState, moveAction); - equalWithEpsilon(getPosition(newState.flycam), [2, 4, 6]); + // Due to initial rotation of 180 degree around z axis, x and y values are inverted. + equalWithEpsilon(getPosition(newState.flycam), [-2, -4, 6]); + }); + + it("should move the flycam absolute without taking base rotation into account", () => { + const moveAction = FlycamActions.moveFlycamAbsoluteAction([1, 2, 3]); + const newState = FlycamReducer(initialState, moveAction); + // The absolute action should move the flycam without taking the rotation into account. So no invert. + equalWithEpsilon(getPosition(newState.flycam), [1, 2, 3]); + }); + + it("should move the flycam absolute backwards without taking base rotation into account", () => { + const moveAction = FlycamActions.moveFlycamAbsoluteAction([-1, -2, -3]); + const newState = FlycamReducer(initialState, moveAction); + // The absolute action should move the flycam without taking the rotation into account. So no invert. + equalWithEpsilon(getPosition(newState.flycam), [-1, -2, -3]); }); it("should set the rotation the flycam", () => { const rotateAction = FlycamActions.setRotationAction([180, 0, 0]); const newState = FlycamReducer(initialState, rotateAction); - equalWithEpsilon(getRotation(newState.flycam), [180, 0, 0]); + equalWithEpsilon(getRotationInDegrees(newState.flycam), [180, 0, 0]); equalWithEpsilon(getUp(newState.flycam), [0, 1, -0]); equalWithEpsilon(getLeft(newState.flycam), [-1, 0, 0]); }); @@ -100,33 +124,33 @@ describe("Flycam", () => { const rotateAction = FlycamActions.rotateFlycamAction(0.5 * Math.PI, [1, 1, 0]); const newState = FlycamReducer(initialState, rotateAction); equalWithEpsilon(getPosition(newState.flycam), [0, 0, 0]); - equalWithEpsilon(V3.floor(getRotation(newState.flycam)), [270, 315, 135]); + equalWithEpsilon(V3.floor(getRotationInDegrees(newState.flycam)), [270, 315, 315]); }); it("should pitch the flycam", () => { const rotateAction = FlycamActions.pitchFlycamAction(0.5 * Math.PI); const newState = FlycamReducer(initialState, rotateAction); equalWithEpsilon(getPosition(newState.flycam), [0, 0, 0]); - equalWithEpsilon(getRotation(newState.flycam), [270, 0, 180]); + equalWithEpsilon(getRotationInDegrees(newState.flycam), [270, 0, 0]); }); it("should pitch the flycam with spherical cap radius", () => { const rotateAction = FlycamActions.pitchFlycamAction(0.5 * Math.PI, true); const newState = FlycamReducer(initialState, rotateAction); - equalWithEpsilon(getPosition(newState.flycam), [0, -200, -200]); - equalWithEpsilon(getRotation(newState.flycam), [270, 0, 180]); + equalWithEpsilon(getPosition(newState.flycam), [0, 200, -200]); + equalWithEpsilon(getRotationInDegrees(newState.flycam), [270, 0, 0]); }); it("should yaw the flycam", () => { const rotateAction = FlycamActions.yawFlycamAction(0.5 * Math.PI); const newState = FlycamReducer(initialState, rotateAction); - equalWithEpsilon(getRotation(newState.flycam), [0, 270, 180]); + equalWithEpsilon(getRotationInDegrees(newState.flycam), [180, 270, 180]); }); it("should roll the flycam", () => { const rotateAction = FlycamActions.rollFlycamAction(0.5 * Math.PI); const newState = FlycamReducer(initialState, rotateAction); - equalWithEpsilon(getRotation(newState.flycam), [0, 0, 90]); + equalWithEpsilon(getRotationInDegrees(newState.flycam), [0, 0, 270]); }); it("should move in ortho mode", () => { diff --git a/frontend/javascripts/test/sagas/proofreading.spec.ts b/frontend/javascripts/test/sagas/proofreading.spec.ts index 93ed28f1740..09ba5b2b42c 100644 --- a/frontend/javascripts/test/sagas/proofreading.spec.ts +++ b/frontend/javascripts/test/sagas/proofreading.spec.ts @@ -292,8 +292,6 @@ describe("Proofreading", () => { }, ]); - yield take("FINISH_MAPPING_INITIALIZATION"); - const mapping1 = yield select( (state) => getMappingInfo(state.temporaryConfiguration.activeMappingByLayer, tracingId).mapping, @@ -304,6 +302,9 @@ describe("Proofreading", () => { yield call(() => api.tracing.save()); expect(context.receivedDataPerSaveRequest).toEqual([]); + + const activeTool = yield select((state) => state.uiInformation.activeTool); + expect(activeTool).toBe(AnnotationTool.PROOFREAD); }); await task.toPromise(); diff --git a/frontend/javascripts/test/shaders/shader_syntax.spec.ts b/frontend/javascripts/test/shaders/shader_syntax.spec.ts index 76094555aa1..5cb1178653f 100644 --- a/frontend/javascripts/test/shaders/shader_syntax.spec.ts +++ b/frontend/javascripts/test/shaders/shader_syntax.spec.ts @@ -47,6 +47,7 @@ describe("Shader syntax", () => { magnificationsCount: mags.length, voxelSizeFactor: [1, 1, 1], isOrthogonal: true, + voxelSizeFactorInverted: [1, 1, 1], tpsTransformPerLayer: {}, }); @@ -98,6 +99,7 @@ describe("Shader syntax", () => { magnificationsCount: mags.length, voxelSizeFactor: [1, 1, 1], isOrthogonal: true, + voxelSizeFactorInverted: [1, 1, 1], tpsTransformPerLayer: {}, }); parser.parse(code); @@ -142,6 +144,7 @@ describe("Shader syntax", () => { magnificationsCount: mags.length, voxelSizeFactor: [1, 1, 1], isOrthogonal: true, + voxelSizeFactorInverted: [1, 1, 1], tpsTransformPerLayer: {}, }); @@ -178,6 +181,7 @@ describe("Shader syntax", () => { magnificationsCount: mags.length, voxelSizeFactor: [1, 1, 1], isOrthogonal: false, + voxelSizeFactorInverted: [1, 1, 1], tpsTransformPerLayer: {}, }); parser.parse(code); @@ -222,6 +226,7 @@ describe("Shader syntax", () => { magnificationsCount: mags.length, voxelSizeFactor: [1, 1, 1], isOrthogonal: false, + voxelSizeFactorInverted: [1, 1, 1], tpsTransformPerLayer: {}, }); parser.parse(code); @@ -257,6 +262,7 @@ describe("Shader syntax", () => { magnificationsCount: mags.length, voxelSizeFactor: [1, 1, 1], isOrthogonal: true, + voxelSizeFactorInverted: [1, 1, 1], tpsTransformPerLayer: {}, }); parser.parse(code); diff --git a/frontend/javascripts/types/schemas/url_state.types.ts b/frontend/javascripts/types/schemas/url_state.types.ts deleted file mode 100644 index a7ad8df2787..00000000000 --- a/frontend/javascripts/types/schemas/url_state.types.ts +++ /dev/null @@ -1,14 +0,0 @@ -// This file is only for documentation: -// Types which were used for creating the url_state.schema.js -// The `flow2schema` node module has been used for conversion. -// Unfortunately as of now, flow2schema doesn't support our code -// base out of the box so all types need to be temporarily copied into -// this file as imports do not work! -// -// Please note that some manual changes to the schema are required, -// i.e. the ::path::to::definition:: prefixes need to be removed -// and "additionalProperties: false," needs to be added to the objects -// to make the object types exact and forbid superfluous properties. -import type { UrlManagerState } from "viewer/controller/url_manager"; - -export type { UrlManagerState }; diff --git a/frontend/javascripts/viewer/api/api_latest.ts b/frontend/javascripts/viewer/api/api_latest.ts index 786d95e5393..5eacd61109a 100644 --- a/frontend/javascripts/viewer/api/api_latest.ts +++ b/frontend/javascripts/viewer/api/api_latest.ts @@ -17,6 +17,7 @@ import { coalesce, mod } from "libs/utils"; import window, { location } from "libs/window"; import _ from "lodash"; import messages from "messages"; +import * as THREE from "three"; import TWEEN from "tween.js"; import { type APICompoundType, APICompoundTypeEnum, type ElementClass } from "types/api_types"; import type { AdditionalCoordinate } from "types/api_types"; @@ -57,7 +58,7 @@ import { flatToNestedMatrix } from "viewer/model/accessors/dataset_layer_transfo import { getActiveMagIndexForLayer, getPosition, - getRotation, + getRotationInRadian, } from "viewer/model/accessors/flycam_accessor"; import { findTreeByNodeId, @@ -283,10 +284,15 @@ class TracingApi { /** * Sets the active node given a node id. */ - setActiveNode(id: number) { + setActiveNode( + id: number, + suppressAnimation?: boolean, + suppressCentering?: boolean, + suppressRotation?: boolean, + ) { assertSkeleton(Store.getState().annotation); assertExists(id, "Node id is missing."); - Store.dispatch(setActiveNodeAction(id)); + Store.dispatch(setActiveNodeAction(id, suppressAnimation, suppressCentering, suppressRotation)); } /** @@ -348,9 +354,14 @@ class TracingApi { } /** - * Creates a new node in the current tree. If the active tree - * is not empty, the node will be connected with an edge to - * the currently active node. + * Creates a new node in the current tree. + * If the active tree already contains nodes, the new node will be connected to the currently active one via an edge. + * + * When the camera is rotated and centering animation is enabled, using unrounded (floating-point) + * coordinates [x, y, z] helps maintain a consistent viewing slice. This prevents the viewports from jumping + * between slices due to the animation. + * + * In scenarios without rotation or centering animation, rounded integer coordinates are sufficient. */ createNode( position: Vector3, @@ -363,10 +374,11 @@ class TracingApi { skipCenteringAnimationInThirdDimension?: boolean; }, ) { + const globalPosition = { rounded: Utils.map3(Math.round, position), floating: position }; assertSkeleton(Store.getState().annotation); const defaultOptions = getOptionsForCreateSkeletonNode(); createSkeletonNode( - position, + globalPosition, options?.additionalCoordinates ?? defaultOptions.additionalCoordinates, options?.rotation ?? defaultOptions.rotation, options?.center ?? defaultOptions.center, @@ -1399,31 +1411,36 @@ class TracingApi { skipCenteringAnimationInThirdDimension: boolean = true, rotation?: Vector3, ): void { - const { activeViewport } = Store.getState().viewModeData.plane; + const { viewModeData, flycam } = Store.getState(); + const { activeViewport } = viewModeData.plane; + const curPosition = getPosition(flycam); + // As the node rotation was calculated in the way the flycam reducer does its matrix rotation + // by using the rotation_helpers.ts there is no need to invert z and we must use XYZ ordered euler angles. + const curRotation = getRotationInRadian(flycam, false); + const startQuaternion = new THREE.Quaternion().setFromEuler( + new THREE.Euler(...curRotation, "XYZ"), + ); + const isNotRotated = V3.equals(curRotation, [0, 0, 0]); const dimensionToSkip = - skipCenteringAnimationInThirdDimension && activeViewport !== OrthoViews.TDView + skipCenteringAnimationInThirdDimension && activeViewport !== OrthoViews.TDView && isNotRotated ? dimensions.thirdDimensionForPlane(activeViewport) : null; - const curPosition = getPosition(Store.getState().flycam); - const curRotation = getRotation(Store.getState().flycam); - if (!Array.isArray(rotation)) rotation = curRotation; - rotation = this.getShortestRotation(curRotation, rotation); + if (rotation == null) { + rotation = curRotation; + } else { + rotation = Utils.map3(THREE.MathUtils.degToRad, rotation); + } + const endQuaternion = new THREE.Quaternion().setFromEuler(new THREE.Euler(...rotation, "XYZ")); type Tweener = { positionX: number; positionY: number; positionZ: number; - rotationX: number; - rotationY: number; - rotationZ: number; }; const tween = new TWEEN.Tween({ positionX: curPosition[0], positionY: curPosition[1], positionZ: curPosition[2], - rotationX: curRotation[0], - rotationY: curRotation[1], - rotationZ: curRotation[2], }); tween .to( @@ -1431,18 +1448,30 @@ class TracingApi { positionX: position[0], positionY: position[1], positionZ: position[2], - rotationX: rotation[0], - rotationY: rotation[1], - rotationZ: rotation[2], }, 200, ) - .onUpdate(function (this: Tweener) { + .onUpdate(function (this: Tweener, t: number) { // needs to be a normal (non-bound) function Store.dispatch( setPositionAction([this.positionX, this.positionY, this.positionZ], dimensionToSkip), ); - Store.dispatch(setRotationAction([this.rotationX, this.rotationY, this.rotationZ])); + // Interpolating rotation via quaternions to get shortest rotation. + const interpolatedQuaternion = new THREE.Quaternion().slerpQuaternions( + startQuaternion, + endQuaternion, + t, + ); + const interpolatedEuler = new THREE.Euler().setFromQuaternion( + interpolatedQuaternion, + "XYZ", + ); + const interpolatedEulerInDegree = Utils.map3(THREE.MathUtils.radToDeg, [ + interpolatedEuler.x, + interpolatedEuler.y, + interpolatedEuler.z, + ]); + Store.dispatch(setRotationAction(interpolatedEulerInDegree)); }) .start(); } diff --git a/frontend/javascripts/viewer/constants.ts b/frontend/javascripts/viewer/constants.ts index 733aa966509..1f73f93a136 100644 --- a/frontend/javascripts/viewer/constants.ts +++ b/frontend/javascripts/viewer/constants.ts @@ -1,3 +1,4 @@ +import * as THREE from "three"; export type AdditionalCoordinate = { name: string; value: number }; export const ViewModeValues = ["orthogonal", "flight", "oblique"] as ViewMode[]; @@ -96,6 +97,21 @@ export const OrthoViewValuesWithoutTDView: Array = [ OrthoViews.PLANE_XZ, ]; +export const OrthoViewToNumber: OrthoViewMap = { + [OrthoViews.PLANE_XY]: 0, + [OrthoViews.PLANE_YZ]: 1, + [OrthoViews.PLANE_XZ]: 2, + [OrthoViews.TDView]: 3, +}; + +export const NumberToOrthoView: Record = { + 0: OrthoViews.PLANE_XY, + 1: OrthoViews.PLANE_YZ, + 2: OrthoViews.PLANE_XZ, + 3: OrthoViews.TDView, + 4: OrthoViews.PLANE_XY, // Arbitrary view is equal to the XY plane. +}; + const PINK = 0xeb4b98; const BLUE = 0x5660ff; const TURQUOISE = 0x59f8e8; @@ -112,6 +128,38 @@ export const OrthoViewCrosshairColors: OrthoViewMap<[number, number]> = { [OrthoViews.PLANE_XZ]: [BLUE, PINK], [OrthoViews.TDView]: [0x000000, 0x000000], }; + +// See the following or an explanation about the relative orientation of the viewports toward the XY viewport. +// https://www.notion.so/scalableminds/3D-Rotations-3D-Scene-210b51644c6380c2a4a6f5f3c069738a?source=copy_link#22bb51644c63800e8682e92a5c91a519 +export const OrthoBaseRotations = { + [OrthoViews.PLANE_XY]: new THREE.Euler(0, 0, 0), + [OrthoViews.PLANE_YZ]: new THREE.Euler(0, (3 / 2) * Math.PI, 0), + [OrthoViews.PLANE_XZ]: new THREE.Euler(Math.PI / 2, 0, 0), + [OrthoViews.TDView]: new THREE.Euler(Math.PI / 4, Math.PI / 4, Math.PI / 4), +}; + +function correctCameraViewingDirection(baseEuler: THREE.Euler): THREE.Euler { + const cameraCorrectionEuler = new THREE.Euler(Math.PI, 0, 0); + const correctedEuler = new THREE.Euler(); + correctedEuler.setFromRotationMatrix( + new THREE.Matrix4() + .makeRotationFromEuler(baseEuler) + .multiply(new THREE.Matrix4().makeRotationFromEuler(cameraCorrectionEuler)), + "ZYX", + ); + + return correctedEuler; +} + +// The orthographic cameras point towards negative z direction per default. To make it look into positive direction of the z axis, +// an additional rotation around x axis by 180° is needed. This is appended via correctCameraViewingDirection. +export const OrthoCamerasBaseRotations = { + [OrthoViews.PLANE_XY]: correctCameraViewingDirection(OrthoBaseRotations[OrthoViews.PLANE_XY]), + [OrthoViews.PLANE_YZ]: correctCameraViewingDirection(OrthoBaseRotations[OrthoViews.PLANE_YZ]), + [OrthoViews.PLANE_XZ]: correctCameraViewingDirection(OrthoBaseRotations[OrthoViews.PLANE_XZ]), + [OrthoViews.TDView]: new THREE.Euler(Math.PI / 4, Math.PI / 4, Math.PI / 4), +}; + export type BorderTabType = { id: string; name: string; diff --git a/frontend/javascripts/viewer/controller/camera_controller.ts b/frontend/javascripts/viewer/controller/camera_controller.ts index eecb179e262..55c0aa5dcb0 100644 --- a/frontend/javascripts/viewer/controller/camera_controller.ts +++ b/frontend/javascripts/viewer/controller/camera_controller.ts @@ -5,9 +5,13 @@ import * as React from "react"; import * as THREE from "three"; import TWEEN from "tween.js"; import type { OrthoView, OrthoViewMap, OrthoViewRects, Vector3 } from "viewer/constants"; -import { OrthoViewValuesWithoutTDView, OrthoViews } from "viewer/constants"; -import { getDatasetCenter, getDatasetExtentInUnit } from "viewer/model/accessors/dataset_accessor"; -import { getPosition } from "viewer/model/accessors/flycam_accessor"; +import { + OrthoCamerasBaseRotations, + OrthoViewValuesWithoutTDView, + OrthoViews, +} from "viewer/constants"; +import { getDatasetExtentInUnit } from "viewer/model/accessors/dataset_accessor"; +import { getPosition, getRotationInRadian } from "viewer/model/accessors/flycam_accessor"; import { getInputCatcherAspectRatio, getPlaneExtentInVoxelFromStore, @@ -30,9 +34,20 @@ function getQuaternionFromCamera(_up: Vector3, position: Vector3, center: Vector const up = V3.normalize(_up); const forward = V3.normalize(V3.sub(center, position)); const right = V3.normalize(V3.cross(up, forward)); + // Up might not be completely orthogonal to forward, so we need to correct it. + // Such tiny error would else lead to a non-orthogonal basis matrix leading to + // potentially very large errors in the calculated quaternion. + const correctedUp = V3.normalize(V3.cross(forward, right)); + + // Create a basis matrix const rotationMatrix = new THREE.Matrix4(); - // biome-ignore format: don't format - rotationMatrix.set(right[0], up[0], forward[0], 0, right[1], up[1], forward[1], 0, right[2], up[2], forward[2], 0, 0, 0, 0, 1); + rotationMatrix.makeBasis( + new THREE.Vector3(...right), + new THREE.Vector3(...correctedUp), + new THREE.Vector3(...forward), + ); + + // Convert to quaternion const quat = new THREE.Quaternion(); quat.setFromRotationMatrix(rotationMatrix); return quat; @@ -66,6 +81,11 @@ function getCameraFromQuaternion(quat: { x: number; y: number; z: number; w: num class CameraController extends React.PureComponent { // @ts-expect-error ts-migrate(2564) FIXME: Property 'storePropertyUnsubscribers' has no initi... Remove this comment to see the full error message storePropertyUnsubscribers: Array<(...args: Array) => any>; + // Properties are only created here to avoid creating new objects for each update call. + flycamRotationEuler = new THREE.Euler(); + flycamRotationMatrix = new THREE.Matrix4(); + baseRotationMatrix = new THREE.Matrix4(); + totalRotationMatrix = new THREE.Matrix4(); componentDidMount() { // Take the whole diagonal extent of the dataset to get the possible maximum extent of the dataset. @@ -153,12 +173,28 @@ class CameraController extends React.PureComponent { update(): void { const state = Store.getState(); - const gPos = getPosition(state.flycam); + const globalPosition = getPosition(state.flycam); // camera position's unit is nm, so convert it. - const cPos = voxelToUnit(state.dataset.dataSource.scale, gPos); - this.props.cameras[OrthoViews.PLANE_XY].position.set(cPos[0], cPos[1], cPos[2]); - this.props.cameras[OrthoViews.PLANE_YZ].position.set(cPos[0], cPos[1], cPos[2]); - this.props.cameras[OrthoViews.PLANE_XZ].position.set(cPos[0], cPos[1], cPos[2]); + const cameraPosition = voxelToUnit(state.dataset.dataSource.scale, globalPosition); + // Now set rotation for all cameras respecting the base rotation of each camera. + const globalRotation = getRotationInRadian(state.flycam); + this.flycamRotationEuler.set(globalRotation[0], globalRotation[1], globalRotation[2], "ZYX"); + this.flycamRotationMatrix.makeRotationFromEuler(this.flycamRotationEuler); + for (const viewport of OrthoViewValuesWithoutTDView) { + this.props.cameras[viewport].position.set( + cameraPosition[0], + cameraPosition[1], + cameraPosition[2], + ); + this.baseRotationMatrix.makeRotationFromEuler(OrthoCamerasBaseRotations[viewport]); + this.props.cameras[viewport].setRotationFromMatrix( + this.totalRotationMatrix + .identity() + .multiply(this.flycamRotationMatrix) + .multiply(this.baseRotationMatrix), + ); + this.props.cameras[viewport].updateProjectionMatrix(); + } } bindToEvents() { @@ -222,6 +258,7 @@ export function rotate3DViewTo( const { dataset } = state; const { tdCamera } = state.viewModeData.plane; const flycamPos = voxelToUnit(dataset.dataSource.scale, getPosition(state.flycam)); + const flycamRotation = getRotationInRadian(state.flycam); const datasetExtent = getDatasetExtentInUnit(dataset); // This distance ensures that the 3D camera is so far "in the back" that all elements in the scene // are in front of it and thus visible. @@ -233,8 +270,6 @@ export function rotate3DViewTo( // Use width and height to keep the same zoom. let width = tdCamera.right - tdCamera.left; let height = tdCamera.top - tdCamera.bottom; - let position: Vector3; - let up: Vector3; // Way to calculate the position and rotation of the camera: // First, the camera is either positioned at the current center of the flycam or in the dataset center. @@ -243,61 +278,49 @@ export function rotate3DViewTo( if (id === OrthoViews.TDView && (height <= 0 || width <= 0)) { // This should only be the case when initializing the 3D-viewport. const aspectRatio = getInputCatcherAspectRatio(state, OrthoViews.TDView); - const datasetCenter = voxelToUnit(dataset.dataSource.scale, getDatasetCenter(dataset)); // The camera has no width and height which might be due to a bug or the camera has not been initialized. // Thus we zoom out to show the whole dataset. const paddingFactor = 1.1; width = Math.sqrt(datasetExtent.width ** 2 + datasetExtent.height ** 2) * paddingFactor; height = width / aspectRatio; - up = [0, 0, -1]; + } + const positionOffsetMap: OrthoViewMap = { + [OrthoViews.PLANE_XY]: [0, 0, -clippingOffsetFactor], + [OrthoViews.PLANE_YZ]: [clippingOffsetFactor, 0, 0], + [OrthoViews.PLANE_XZ]: [0, clippingOffsetFactor, 0], // For very tall datasets that have a very low or high z starting coordinate, the planes might not be visible. // Thus take the z coordinate of the flycam instead of the z coordinate of the center. // The clippingOffsetFactor is added in x and y direction to get a view on the dataset the 3D view that is close to the plane views. // Thus the rotation between the 3D view to the eg. XY plane views is much shorter and the interpolated rotation does not look weird. - position = [ - datasetCenter[0] + clippingOffsetFactor, - datasetCenter[1] + clippingOffsetFactor, - flycamPos[2] - clippingOffsetFactor, - ]; - } else if (id === OrthoViews.TDView) { - position = [ - flycamPos[0] + clippingOffsetFactor, - flycamPos[1] + clippingOffsetFactor, - flycamPos[2] - clippingOffsetFactor, - ]; - up = [0, 0, -1]; - } else { - const positionOffset: OrthoViewMap = { - [OrthoViews.PLANE_XY]: [0, 0, -clippingOffsetFactor], - [OrthoViews.PLANE_YZ]: [clippingOffsetFactor, 0, 0], - [OrthoViews.PLANE_XZ]: [0, clippingOffsetFactor, 0], - [OrthoViews.TDView]: [0, 0, 0], - }; - const upVector: OrthoViewMap = { - [OrthoViews.PLANE_XY]: [0, -1, 0], - [OrthoViews.PLANE_YZ]: [0, -1, 0], - [OrthoViews.PLANE_XZ]: [0, 0, -1], - [OrthoViews.TDView]: [0, 0, 0], - }; - up = upVector[id]; - position = [ - positionOffset[id][0] + flycamPos[0], - positionOffset[id][1] + flycamPos[1], - positionOffset[id][2] + flycamPos[2], - ]; - } + // The z offset is halved to have a lower viewing angle at the plane. + [OrthoViews.TDView]: [clippingOffsetFactor, clippingOffsetFactor, -clippingOffsetFactor / 2], + }; + const upVectorMap: OrthoViewMap = { + [OrthoViews.PLANE_XY]: [0, -1, 0], + [OrthoViews.PLANE_YZ]: [0, -1, 0], + [OrthoViews.PLANE_XZ]: [0, 0, -1], + [OrthoViews.TDView]: [0, 0, -1], + }; + + const positionOffsetVector = new THREE.Vector3(...positionOffsetMap[id]); + const upVector = new THREE.Vector3(...upVectorMap[id]); + // Rotate the positionOffsetVector and upVector by the flycam rotation. + const rotatedOffset = positionOffsetVector.applyEuler(new THREE.Euler(...flycamRotation, "ZYX")); + const rotatedUp = upVector.applyEuler(new THREE.Euler(...flycamRotation, "ZYX")); + const position = [ + flycamPos[0] + rotatedOffset.x, + flycamPos[1] + rotatedOffset.y, + flycamPos[2] + rotatedOffset.z, + ] as Vector3; + const up = [rotatedUp.x, rotatedUp.y, rotatedUp.z] as Vector3; - const currentFlycamPos = voxelToUnit( - Store.getState().dataset.dataSource.scale, - getPosition(Store.getState().flycam), - ) || [0, 0, 0]; // Compute current and target orientation as quaternion. When tweening between // these orientations, we compute the new camera position by keeping the distance // (radius) to currentFlycamPos constant. Consequently, the camera moves on the // surfaces of a sphere with the center at currentFlycamPos. - const startQuaternion = getQuaternionFromCamera(tdCamera.up, tdCamera.position, currentFlycamPos); - const targetQuaternion = getQuaternionFromCamera(up, position, currentFlycamPos); - const centerDistance = V3.length(V3.sub(currentFlycamPos, position)); + const startQuaternion = getQuaternionFromCamera(tdCamera.up, tdCamera.position, flycamPos); + const targetQuaternion = getQuaternionFromCamera(up, position, flycamPos); + const centerDistance = V3.length(V3.sub(flycamPos, position)); const to: TweenState = { left: -width / 2, right: width / 2, @@ -312,9 +335,7 @@ export function rotate3DViewTo( const tweened = getCameraFromQuaternion(tweenedQuat); // Use forward vector and currentFlycamPos (lookAt target) to calculate the current // camera's position which should be on a sphere (center=currentFlycamPos, radius=centerDistance). - const newPosition = V3.toArray( - V3.sub(currentFlycamPos, V3.scale(tweened.forward, centerDistance)), - ); + const newPosition = V3.toArray(V3.sub(flycamPos, V3.scale(tweened.forward, centerDistance))); Store.dispatch( setTDCameraWithoutTimeTrackingAction({ position: newPosition, diff --git a/frontend/javascripts/viewer/controller/combinations/bounding_box_handlers.ts b/frontend/javascripts/viewer/controller/combinations/bounding_box_handlers.ts index 3c19a064892..05c35859aeb 100644 --- a/frontend/javascripts/viewer/controller/combinations/bounding_box_handlers.ts +++ b/frontend/javascripts/viewer/controller/combinations/bounding_box_handlers.ts @@ -164,8 +164,8 @@ export function getClosestHoveredBoundingBox( ): [SelectedEdge, SelectedEdge | null | undefined] | null { const state = Store.getState(); const globalPosition = calculateMaybeGlobalPos(state, pos, plane); - - if (globalPosition == null) return null; + if (globalPosition == null || globalPosition.rounded == null) return null; + const roundedPosition = globalPosition.rounded; const { userBoundingBoxes } = getSomeTracing(state.annotation); const indices = Dimension.getIndices(plane); @@ -179,7 +179,7 @@ export function getClosestHoveredBoundingBox( for (const bbox of userBoundingBoxes) { const { min, max } = bbox.boundingBox; const isCrossSectionOfViewportVisible = - globalPosition[thirdDim] >= min[thirdDim] && globalPosition[thirdDim] < max[thirdDim]; + roundedPosition[thirdDim] >= min[thirdDim] && roundedPosition[thirdDim] < max[thirdDim]; if (!isCrossSectionOfViewportVisible || !bbox.isVisible) { continue; @@ -189,7 +189,7 @@ export function getClosestHoveredBoundingBox( // of how the indices of the array map to the visible bbox edges. const distanceArray = computeDistanceArray( bbox.boundingBox, - globalPosition, + globalPosition.rounded, indices, planeRatio, ); @@ -236,16 +236,15 @@ export function createBoundingBoxAndGetEdges( ): [SelectedEdge, SelectedEdge | null | undefined] | null { const state = Store.getState(); const globalPosition = calculateMaybeGlobalPos(state, pos, plane); - - if (globalPosition == null) return null; + if (globalPosition == null || globalPosition.rounded == null) return null; Store.dispatch( addUserBoundingBoxAction({ boundingBox: { - min: globalPosition, + min: globalPosition.rounded, // The last argument ensures that a Vector3 is used and not a // Float32Array. - max: V3.add(globalPosition, [1, 1, 1], [0, 0, 0]), + max: V3.add(globalPosition.rounded, [1, 1, 1], [0, 0, 0]), }, }), ); @@ -319,7 +318,7 @@ export function handleResizingBoundingBox( const globalMousePosition = calculateGlobalPos(state, mousePosition, planeId); const bboxToResize = getBoundingBoxOfPrimaryEdge(primaryEdge, state); - if (!bboxToResize) { + if (!bboxToResize || globalMousePosition == null || globalMousePosition.rounded == null) { return; } @@ -328,10 +327,13 @@ export function handleResizingBoundingBox( max: [...bboxToResize.boundingBox.max] as Vector3, }; - function updateBoundsAccordingToEdge(edge: SelectedEdge): boolean { + function updateBoundsAccordingToEdge( + edge: SelectedEdge, + globalPositionRounded: Vector3, + ): boolean { const { resizableDimension } = edge; // For a horizontal edge only consider delta.y, for vertical only delta.x - const newPositionValue = Math.round(globalMousePosition[resizableDimension]); + const newPositionValue = Math.round(globalPositionRounded[resizableDimension]); const minOrMax = edge.isMaxEdge ? "max" : "min"; const oppositeOfMinOrMax = edge.isMaxEdge ? "min" : "max"; // @ts-expect-error ts-migrate(2532) FIXME: Object is possibly 'undefined'. @@ -357,14 +359,20 @@ export function handleResizingBoundingBox( } } - let didMinAndMaxEdgeSwitch = updateBoundsAccordingToEdge(primaryEdge); + let didMinAndMaxEdgeSwitch = updateBoundsAccordingToEdge( + primaryEdge, + globalMousePosition.rounded, + ); if (didMinAndMaxEdgeSwitch) { primaryEdge.isMaxEdge = !primaryEdge.isMaxEdge; } if (secondaryEdge) { - didMinAndMaxEdgeSwitch = updateBoundsAccordingToEdge(secondaryEdge); + didMinAndMaxEdgeSwitch = updateBoundsAccordingToEdge( + secondaryEdge, + globalMousePosition.rounded, + ); if (didMinAndMaxEdgeSwitch) { secondaryEdge.isMaxEdge = !secondaryEdge.isMaxEdge; diff --git a/frontend/javascripts/viewer/controller/combinations/move_handlers.ts b/frontend/javascripts/viewer/controller/combinations/move_handlers.ts index 0a703fedd65..4bce751bfa8 100644 --- a/frontend/javascripts/viewer/controller/combinations/move_handlers.ts +++ b/frontend/javascripts/viewer/controller/combinations/move_handlers.ts @@ -1,9 +1,11 @@ +import { V3 } from "libs/mjs"; import type { OrthoView, Point2, Vector3 } from "viewer/constants"; import { OrthoViewValuesWithoutTDView, OrthoViews } from "viewer/constants"; import { is2dDataset } from "viewer/model/accessors/dataset_accessor"; import { getActiveMagInfo } from "viewer/model/accessors/flycam_accessor"; import { calculateGlobalPos, getInputCatcherRect } from "viewer/model/accessors/view_mode_accessor"; import { + moveFlycamAbsoluteAction, moveFlycamOrthoAction, movePlaneFlycamOrthoAction, zoomByDeltaAction, @@ -35,12 +37,12 @@ export const moveV = (deltaV: number): void => { movePlane([0, deltaV, 0]); }; export const moveW = (deltaW: number, oneSlide: boolean): void => { - if (is2dDataset(Store.getState().dataset)) { + const state = Store.getState(); + if (is2dDataset(state.dataset)) { return; } - const { activeViewport } = Store.getState().viewModeData.plane; - + const { activeViewport } = state.viewModeData.plane; if (activeViewport === OrthoViews.TDView) { return; } @@ -49,7 +51,7 @@ export const moveW = (deltaW: number, oneSlide: boolean): void => { // The following logic might not always make sense when having layers // that are transformed each. Todo: Rethink / adapt the logic once // problems occur. Tracked in #6926. - const { representativeMag } = getActiveMagInfo(Store.getState()); + const { representativeMag } = getActiveMagInfo(state); const wDim = Dimensions.getIndices(activeViewport)[2]; const wStep = (representativeMag || [1, 1, 1])[wDim]; Store.dispatch( @@ -59,7 +61,7 @@ export const moveW = (deltaW: number, oneSlide: boolean): void => { ), ); } else { - movePlane([0, 0, deltaW], false); + Store.dispatch(movePlaneFlycamOrthoAction([0, 0, deltaW], activeViewport, false)); } }; export function moveWhenAltIsPressed(delta: Point2, position: Point2, _id: any, event: MouseEvent) { @@ -93,7 +95,7 @@ function getMousePosition() { return calculateGlobalPos(state, { x: mousePosition[0], y: mousePosition[1], - }); + }).floating; } export function zoomPlanes(value: number, zoomToMouse: boolean): void { @@ -122,12 +124,7 @@ function finishZoom(oldMousePosition: Vector3): void { return; } - const moveVector = [ - oldMousePosition[0] - mousePos[0], - oldMousePosition[1] - mousePos[1], - oldMousePosition[2] - mousePos[2], - ]; - // @ts-expect-error ts-migrate(2345) FIXME: Argument of type 'number[]' is not assignable to p... Remove this comment to see the full error message - Store.dispatch(moveFlycamOrthoAction(moveVector, activeViewport)); + const moveVector = V3.sub(oldMousePosition, mousePos); + Store.dispatch(moveFlycamAbsoluteAction(moveVector)); } } diff --git a/frontend/javascripts/viewer/controller/combinations/segmentation_handlers.ts b/frontend/javascripts/viewer/controller/combinations/segmentation_handlers.ts index f2faf6bb9be..93e3ff227f2 100644 --- a/frontend/javascripts/viewer/controller/combinations/segmentation_handlers.ts +++ b/frontend/javascripts/viewer/controller/combinations/segmentation_handlers.ts @@ -20,7 +20,7 @@ import Store from "viewer/store"; export async function handleAgglomerateSkeletonAtClick(clickPosition: Point2) { const state = Store.getState(); const globalPosition = calculateGlobalPos(state, clickPosition); - loadAgglomerateSkeletonAtPosition(globalPosition); + loadAgglomerateSkeletonAtPosition(globalPosition.rounded); } export async function loadAgglomerateSkeletonAtPosition(position: Vector3): Promise { const segmentation = Model.getVisibleSegmentationLayer(); @@ -78,10 +78,10 @@ export async function loadSynapsesOfAgglomerateAtPosition(position: Vector3) { export function handleClickSegment(clickPosition: Point2) { const state = Store.getState(); const globalPosition = calculateGlobalPos(state, clickPosition); - const segmentId = getSegmentIdForPosition(globalPosition); + const segmentId = getSegmentIdForPosition(globalPosition.rounded); const { additionalCoordinates } = state.flycam; if (segmentId > 0) { - Store.dispatch(clickSegmentAction(segmentId, globalPosition, additionalCoordinates)); + Store.dispatch(clickSegmentAction(segmentId, globalPosition.rounded, additionalCoordinates)); } } diff --git a/frontend/javascripts/viewer/controller/combinations/skeleton_handlers.ts b/frontend/javascripts/viewer/controller/combinations/skeleton_handlers.ts index 3b5d429d46e..6df28f7764a 100644 --- a/frontend/javascripts/viewer/controller/combinations/skeleton_handlers.ts +++ b/frontend/javascripts/viewer/controller/combinations/skeleton_handlers.ts @@ -3,16 +3,18 @@ import { values } from "libs/utils"; import _ from "lodash"; import * as THREE from "three"; import type { AdditionalCoordinate } from "types/api_types"; -import type { OrthoView, OrthoViewMap, Point2, Vector3, Viewport } from "viewer/constants"; -import { OrthoViews } from "viewer/constants"; +import type { OrthoView, Point2, Vector3, Viewport } from "viewer/constants"; +import { OrthoBaseRotations, OrthoViewToNumber, OrthoViews } from "viewer/constants"; import { getClosestHoveredBoundingBox } from "viewer/controller/combinations/bounding_box_handlers"; import getSceneController from "viewer/controller/scene_controller_provider"; import { getEnabledColorLayers } from "viewer/model/accessors/dataset_accessor"; import { getActiveMagIndicesForLayers, + getFlycamRotationWithAppendedRotation, getPosition, - getRotationOrtho, + getRotationInRadian, isMagRestrictionViolated, + isRotated, } from "viewer/model/accessors/flycam_accessor"; import { enforceSkeletonTracing, @@ -24,6 +26,7 @@ import { untransformNodePosition, } from "viewer/model/accessors/skeletontracing_accessor"; import { + type PositionWithRounding, calculateGlobalPos, calculateMaybeGlobalPos, getInputCatcherRect, @@ -48,12 +51,7 @@ import Store from "viewer/store"; import type ArbitraryView from "viewer/view/arbitrary_view"; import type PlaneView from "viewer/view/plane_view"; import { renderToTexture } from "viewer/view/rendering_utils"; -const OrthoViewToNumber: OrthoViewMap = { - [OrthoViews.PLANE_XY]: 0, - [OrthoViews.PLANE_YZ]: 1, - [OrthoViews.PLANE_XZ]: 2, - [OrthoViews.TDView]: 3, -}; + export function handleMergeTrees( view: PlaneView | ArbitraryView, position: Point2, @@ -160,7 +158,7 @@ export function handleOpenContextMenu( event.pageY, nodeId, clickedBoundingBoxId, - globalPosition, + globalPosition?.rounded, activeViewport, meshId, meshIntersectionPosition, @@ -170,6 +168,12 @@ export function handleOpenContextMenu( 0, ); } + +// Already defined here at toplevel to avoid object recreation with each call. Make sure to not do anything async between read and writes. +const flycamRotationEuler = new THREE.Euler(); +const flycamRotationMatrix = new THREE.Matrix4(); +const movementVector = new THREE.Vector3(); + export function moveNode( dx: number, dy: number, @@ -181,7 +185,8 @@ export function moveNode( useFloat: boolean = false, ) { // dx and dy are measured in pixel. - const skeletonTracing = getSkeletonTracing(Store.getState().annotation); + const state = Store.getState(); + const skeletonTracing = getSkeletonTracing(state.annotation); if (!skeletonTracing) return; const treeAndNode = getTreeAndNode(skeletonTracing, nodeId); @@ -189,14 +194,19 @@ export function moveNode( const [activeTree, activeNode] = treeAndNode; - const state = Store.getState(); const { activeViewport } = state.viewModeData.plane; const vector = Dimensions.transDim([dx, dy, 0], activeViewport); + const flycamRotation = getRotationInRadian(state.flycam); + const isFlycamRotated = isRotated(state.flycam); + + flycamRotationMatrix.makeRotationFromEuler(flycamRotationEuler.set(...flycamRotation, "ZYX")); + const vectorRotated = movementVector.set(...vector).applyMatrix4(flycamRotationMatrix); + const zoomFactor = state.flycam.zoomStep; const scaleFactor = getBaseVoxelFactorsInUnit(state.dataset.dataSource.scale); const op = (val: number) => { - if (useFloat) { + if (useFloat || isFlycamRotated) { return val; } // Zero diffs should stay zero. @@ -212,9 +222,9 @@ export function moveNode( }; const delta = [ - op(vector[0] * zoomFactor * scaleFactor[0]), - op(vector[1] * zoomFactor * scaleFactor[1]), - op(vector[2] * zoomFactor * scaleFactor[2]), + op(vectorRotated.x * zoomFactor * scaleFactor[0]), + op(vectorRotated.y * zoomFactor * scaleFactor[1]), + op(vectorRotated.z * zoomFactor * scaleFactor[2]), ]; const [x, y, z] = getNodePosition(activeNode, state); @@ -246,7 +256,7 @@ export function finishNodeMovement(nodeId: number) { } export function handleCreateNodeFromGlobalPosition( - position: Vector3, + nodePosition: PositionWithRounding, activeViewport: OrthoView, ctrlIsPressed: boolean, ): void { @@ -267,7 +277,7 @@ export function handleCreateNodeFromGlobalPosition( skipCenteringAnimationInThirdDimension, } = getOptionsForCreateSkeletonNode(activeViewport, ctrlIsPressed); createSkeletonNode( - position, + nodePosition, additionalCoordinates, rotation, center, @@ -285,7 +295,12 @@ export function getOptionsForCreateSkeletonNode( const additionalCoordinates = state.flycam.additionalCoordinates; const skeletonTracing = enforceSkeletonTracing(state.annotation); const activeNode = getActiveNode(skeletonTracing); - const rotation = getRotationOrtho(activeViewport || state.viewModeData.plane.activeViewport); + const initialViewportRotation = + OrthoBaseRotations[activeViewport || state.viewModeData.plane.activeViewport]; + const rotationInDegree = getFlycamRotationWithAppendedRotation( + state.flycam, + initialViewportRotation, + ); // Center node if the corresponding setting is true. Only pressing CTRL can override this. const center = state.userConfiguration.centerNewNode && !ctrlIsPressed; @@ -299,10 +314,9 @@ export function getOptionsForCreateSkeletonNode( const activate = !ctrlIsPressed || activeNode == null; const skipCenteringAnimationInThirdDimension = true; - return { additionalCoordinates, - rotation, + rotation: rotationInDegree, center, branchpoint, activate, @@ -311,7 +325,7 @@ export function getOptionsForCreateSkeletonNode( } export function createSkeletonNode( - position: Vector3, + position: PositionWithRounding, additionalCoordinates: AdditionalCoordinate[] | null, rotation: Vector3, center: boolean, @@ -319,7 +333,7 @@ export function createSkeletonNode( activate: boolean, skipCenteringAnimationInThirdDimension: boolean, ): void { - updateTraceDirection(position); + updateTraceDirection(position.rounded); let state = Store.getState(); const enabledColorLayers = getEnabledColorLayers(state.dataset, state.datasetConfiguration); @@ -333,7 +347,7 @@ export function createSkeletonNode( Store.dispatch( createNodeAction( - untransformNodePosition(position, state), + untransformNodePosition(position.floating, state), additionalCoordinates, rotation, OrthoViewToNumber[Store.getState().viewModeData.plane.activeViewport], @@ -358,12 +372,7 @@ export function createSkeletonNode( const treeAndNode = getTreeAndNode(newSkeleton, newNodeId, newSkeleton.activeTreeId); if (!treeAndNode) return; - const [_activeTree, newNode] = treeAndNode; - - api.tracing.centerPositionAnimated( - getNodePosition(newNode, state), - skipCenteringAnimationInThirdDimension, - ); + api.tracing.centerPositionAnimated(position.floating, skipCenteringAnimationInThirdDimension); } if (branchpoint) { @@ -480,7 +489,9 @@ export function toSubsequentNode(): void { isValidList ) { // navigate to subsequent node in list - Store.dispatch(setActiveNodeAction(navigationList.list[navigationList.activeIndex + 1])); + Store.dispatch( + setActiveNodeAction(navigationList.list[navigationList.activeIndex + 1], false, false), + ); Store.dispatch(updateNavigationListAction(navigationList.list, navigationList.activeIndex + 1)); } else { // search for subsequent node in tree diff --git a/frontend/javascripts/viewer/controller/combinations/tool_controls.ts b/frontend/javascripts/viewer/controller/combinations/tool_controls.ts index 7fc6832bb44..47d57f61959 100644 --- a/frontend/javascripts/viewer/controller/combinations/tool_controls.ts +++ b/frontend/javascripts/viewer/controller/combinations/tool_controls.ts @@ -293,7 +293,7 @@ export class SkeletonToolController { if (plane) { const globalPosition = calculateGlobalPos(Store.getState(), pos); - api.tracing.createNode(globalPosition, { center: false }); + api.tracing.createNode(globalPosition.rounded, { center: false }); } } else { if ( @@ -760,7 +760,7 @@ export class QuickSelectToolController { const [h, s, l] = getSegmentColorAsHSLA(state, volumeTracing.activeCellId); const activeCellColor = new THREE.Color().setHSL(h, s, l); quickSelectGeometry.setColor(activeCellColor); - startPos = V3.floor(calculateGlobalPos(state, pos)); + startPos = calculateGlobalPos(state, pos).rounded; currentPos = startPos; isDragging = true; }, @@ -794,7 +794,7 @@ export class QuickSelectToolController { ) { return; } - const newCurrentPos = V3.floor(calculateGlobalPos(Store.getState(), pos)); + const newCurrentPos = calculateGlobalPos(Store.getState(), pos).rounded; if (event.shiftKey) { // If shift is held, the rectangle is resized on topLeft and bottomRight // so that the center is constant. @@ -813,7 +813,7 @@ export class QuickSelectToolController { }, leftClick: (pos: Point2, _plane: OrthoView, _event: MouseEvent, _isTouch: boolean) => { const state = Store.getState(); - const clickedPos = V3.floor(calculateGlobalPos(state, pos)); + const clickedPos = calculateGlobalPos(state, pos).rounded; isDragging = false; const quickSelectConfig = state.userConfiguration.quickSelect; @@ -894,7 +894,7 @@ export class LineMeasurementToolController { return; } const state = Store.getState(); - const newPos = V3.floor(calculateGlobalPos(state, pos, this.initialPlane)); + const newPos = calculateGlobalPos(state, pos, this.initialPlane).floating; lineMeasurementGeometry.updateLatestPointPosition(newPos); Store.dispatch(setLastMeasuredPositionAction(newPos)); }; @@ -936,7 +936,7 @@ export class LineMeasurementToolController { } // Set a new measurement point. const state = Store.getState(); - const position = V3.floor(calculateGlobalPos(state, pos, plane)); + const position = calculateGlobalPos(state, pos, plane).floating; if (!this.isMeasuring) { this.initialPlane = plane; lineMeasurementGeometry.setStartPoint(position, plane); @@ -1014,7 +1014,7 @@ export class AreaMeasurementToolController { return; } const state = Store.getState(); - const position = V3.floor(calculateGlobalPos(state, pos, this.initialPlane)); + const position = calculateGlobalPos(state, pos, this.initialPlane).floating; areaMeasurementGeometry.addEdgePoint(position); Store.dispatch(setLastMeasuredPositionAction(position)); }, @@ -1079,7 +1079,7 @@ export class ProofreadToolController { } const state = Store.getState(); - const globalPosition = calculateGlobalPos(state, pos); + const globalPosition = calculateGlobalPos(state, pos).rounded; if (event.shiftKey) { Store.dispatch(proofreadMergeAction(globalPosition)); diff --git a/frontend/javascripts/viewer/controller/combinations/volume_handlers.ts b/frontend/javascripts/viewer/controller/combinations/volume_handlers.ts index c3a6572f418..0ae5c7a905b 100644 --- a/frontend/javascripts/viewer/controller/combinations/volume_handlers.ts +++ b/frontend/javascripts/viewer/controller/combinations/volume_handlers.ts @@ -21,17 +21,18 @@ import { Model, Store, api } from "viewer/singletons"; export function handleDrawStart(pos: Point2, plane: OrthoView) { const state = Store.getState(); + const globalPosRounded = calculateGlobalPos(state, pos).rounded; Store.dispatch(setContourTracingModeAction(ContourModeEnum.DRAW)); - Store.dispatch(startEditingAction(calculateGlobalPos(state, pos), plane)); - Store.dispatch(addToLayerAction(calculateGlobalPos(state, pos))); + Store.dispatch(startEditingAction(globalPosRounded, plane)); + Store.dispatch(addToLayerAction(globalPosRounded)); } export function handleEraseStart(pos: Point2, plane: OrthoView) { Store.dispatch(setContourTracingModeAction(ContourModeEnum.DELETE)); - Store.dispatch(startEditingAction(calculateGlobalPos(Store.getState(), pos), plane)); + Store.dispatch(startEditingAction(calculateGlobalPos(Store.getState(), pos).rounded, plane)); } export function handleMoveForDrawOrErase(pos: Point2) { const state = Store.getState(); - Store.dispatch(addToLayerAction(calculateGlobalPos(state, pos))); + Store.dispatch(addToLayerAction(calculateGlobalPos(state, pos).rounded)); } export function handleEndForDrawOrErase() { Store.dispatch(finishEditingAction()); @@ -39,9 +40,12 @@ export function handleEndForDrawOrErase() { } export function handlePickCell(pos: Point2) { const storeState = Store.getState(); - const globalPos = calculateGlobalPos(storeState, pos); + const globalPosRounded = calculateGlobalPos(storeState, pos).rounded; - return handlePickCellFromGlobalPosition(globalPos, storeState.flycam.additionalCoordinates || []); + return handlePickCellFromGlobalPosition( + globalPosRounded, + storeState.flycam.additionalCoordinates || [], + ); } export const getSegmentIdForPosition = memoizeOne( (globalPos: Vector3) => { @@ -139,8 +143,8 @@ export function handlePickCellFromGlobalPosition( ); } export function handleFloodFill(pos: Point2, plane: OrthoView) { - const globalPos = calculateGlobalPos(Store.getState(), pos); - handleFloodFillFromGlobalPosition(globalPos, plane); + const globalPosRounded = calculateGlobalPos(Store.getState(), pos).rounded; + handleFloodFillFromGlobalPosition(globalPosRounded, plane); } export function handleFloodFillFromGlobalPosition(globalPos: Vector3, plane: OrthoView) { Store.dispatch(floodFillAction(globalPos, plane)); diff --git a/frontend/javascripts/viewer/controller/scene_controller.ts b/frontend/javascripts/viewer/controller/scene_controller.ts index 411a56b3937..a38a11bf85d 100644 --- a/frontend/javascripts/viewer/controller/scene_controller.ts +++ b/frontend/javascripts/viewer/controller/scene_controller.ts @@ -1,5 +1,4 @@ import app from "app"; -import { V3 } from "libs/mjs"; import Toast from "libs/toast"; import * as Utils from "libs/utils"; import window from "libs/window"; @@ -10,6 +9,7 @@ import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from "three- import type { BoundingBoxMinMaxType } from "types/bounding_box"; import type { OrthoView, OrthoViewMap, OrthoViewWithoutTDMap, Vector3 } from "viewer/constants"; import constants, { + OrthoBaseRotations, OrthoViews, OrthoViewValuesWithoutTDView, TDViewDisplayModeEnum, @@ -41,7 +41,11 @@ import { getTransformsForLayerOrNull, getTransformsForSkeletonLayer, } from "viewer/model/accessors/dataset_layer_transformation_accessor"; -import { getActiveMagIndicesForLayers, getPosition } from "viewer/model/accessors/flycam_accessor"; +import { + getActiveMagIndicesForLayers, + getPosition, + getRotationInRadian, +} from "viewer/model/accessors/flycam_accessor"; import { getSkeletonTracing } from "viewer/model/accessors/skeletontracing_accessor"; import { getSomeTracing, getTaskBoundingBoxes } from "viewer/model/accessors/tracing_accessor"; import { getPlaneScalingFactor } from "viewer/model/accessors/view_mode_accessor"; @@ -49,7 +53,6 @@ import { sceneControllerReadyAction } from "viewer/model/actions/actions"; import Dimensions from "viewer/model/dimensions"; import { listenToStoreProperty } from "viewer/model/helpers/listener_helpers"; import type { Transform } from "viewer/model/helpers/transformation_helpers"; -import { getVoxelPerUnit } from "viewer/model/scaleinfo"; import { Model } from "viewer/singletons"; import type { SkeletonTracing, UserBoundingBox, WebknossosState } from "viewer/store"; import Store from "viewer/store"; @@ -71,7 +74,7 @@ const getVisibleSegmentationLayerNames = reuseInstanceOnEquality((storeState: We class SceneController { skeletons: Record = {}; isPlaneVisible: OrthoViewMap; - planeShift: Vector3; + clippingDistanceInUnit: number; datasetBoundingBox!: Cube; userBoundingBoxGroup!: THREE.Group; layerBoundingBoxGroup!: THREE.Group; @@ -93,6 +96,10 @@ class SceneController { storePropertyUnsubscribers: Array<() => void>; splitBoundaryMesh: THREE.Mesh | null = null; + // Created as instance properties to avoid creating objects in each update call. + private rotatedPositionOffsetVector = new THREE.Vector3(); + private flycamRotationEuler = new THREE.Euler(); + // This class collects all the meshes displayed in the Skeleton View and updates position and scale of each // element depending on the provided flycam. constructor() { @@ -102,7 +109,7 @@ class SceneController { [OrthoViews.PLANE_XZ]: true, [OrthoViews.TDView]: true, }; - this.planeShift = [0, 0, 0]; + this.clippingDistanceInUnit = 0; this.segmentMeshController = new SegmentMeshController(); this.storePropertyUnsubscribers = []; } @@ -237,11 +244,22 @@ class SceneController { [OrthoViews.PLANE_YZ]: new Plane(OrthoViews.PLANE_YZ), [OrthoViews.PLANE_XZ]: new Plane(OrthoViews.PLANE_XZ), }; - this.planes[OrthoViews.PLANE_XY].setRotation(new THREE.Euler(Math.PI, 0, 0)); - this.planes[OrthoViews.PLANE_YZ].setRotation(new THREE.Euler(Math.PI, (1 / 2) * Math.PI, 0)); - this.planes[OrthoViews.PLANE_XZ].setRotation(new THREE.Euler((-1 / 2) * Math.PI, 0, 0)); + this.planes[OrthoViews.PLANE_XY].setBaseRotation(OrthoBaseRotations[OrthoViews.PLANE_XY]); + this.planes[OrthoViews.PLANE_YZ].setBaseRotation(OrthoBaseRotations[OrthoViews.PLANE_YZ]); + this.planes[OrthoViews.PLANE_XZ].setBaseRotation(OrthoBaseRotations[OrthoViews.PLANE_XZ]); + + const planeGroup = new THREE.Group(); + for (const plane of _.values(this.planes)) { + planeGroup.add(...plane.getMeshes()); + } + // Apply the inverse dataset scale factor to all planes to remove the scaling of the root group + // to avoid shearing effects on rotated ortho viewport planes. For more info see plane.ts. + planeGroup.scale.copy( + new THREE.Vector3(1, 1, 1).divide( + new THREE.Vector3(...Store.getState().dataset.dataSource.scale.factor), + ), + ); - const planeMeshes = _.values(this.planes).flatMap((plane) => plane.getMeshes()); this.rootNode = new THREE.Group().add( this.userBoundingBoxGroup, this.layerBoundingBoxGroup, @@ -252,7 +270,7 @@ class SceneController { ...this.areaMeasurementGeometry.getMeshes(), ), ...this.datasetBoundingBox.getMeshes(), - ...planeMeshes, + planeGroup, ); if (state.annotation.skeleton != null) { @@ -374,15 +392,16 @@ class SceneController { // This method is called for each of the four cams. Even // though they are all looking at the same scene, some // things have to be changed for each cam. + const { datasetConfiguration, userConfiguration, flycam } = Store.getState(); const { tdViewDisplayPlanes, tdViewDisplayDatasetBorders, tdViewDisplayLayerBorders } = - Store.getState().userConfiguration; + userConfiguration; // Only set the visibility of the dataset bounding box for the TDView. // This has to happen before updateForCam is called as otherwise cross section visibility // might be changed unintentionally. this.datasetBoundingBox.setVisibility(id !== OrthoViews.TDView || tdViewDisplayDatasetBorders); this.datasetBoundingBox.updateForCam(id); this.userBoundingBoxes.forEach((bbCube) => bbCube.updateForCam(id)); - const layerNameToIsDisabled = getLayerNameToIsDisabled(Store.getState().datasetConfiguration); + const layerNameToIsDisabled = getLayerNameToIsDisabled(datasetConfiguration); Object.keys(this.layerBoundingBoxes).forEach((layerName) => { const bbCube = this.layerBoundingBoxes[layerName]; const visible = @@ -400,20 +419,29 @@ class SceneController { this.annotationToolsGeometryGroup.visible = id !== OrthoViews.TDView; this.lineMeasurementGeometry.updateForCam(id); - const originalPosition = getPosition(Store.getState().flycam); + const originalPosition = getPosition(flycam); + const rotation = getRotationInRadian(flycam); if (id !== OrthoViews.TDView) { for (const planeId of OrthoViewValuesWithoutTDView) { if (planeId === id) { this.planes[planeId].setOriginalCrosshairColor(); this.planes[planeId].setVisible(!hidePlanes); - - const pos = _.clone(originalPosition); + this.flycamRotationEuler.set(...rotation, "ZYX"); const ind = Dimensions.getIndices(planeId); - // Offset the plane so the user can see the skeletonTracing behind the plane - pos[ind[2]] += - planeId === OrthoViews.PLANE_XY ? this.planeShift[ind[2]] : -this.planeShift[ind[2]]; - this.planes[planeId].setPosition(pos, originalPosition); + // Offset the plane so the user can see the skeletonTracing behind the plane. + // The offset is passed to the shader as a uniform to be subtracted from the position to render the correct data. + const unrotatedPositionOffset = [0, 0, 0] as Vector3; + unrotatedPositionOffset[ind[2]] = + planeId === OrthoViews.PLANE_XY + ? Math.floor(this.clippingDistanceInUnit) + : Math.floor(-this.clippingDistanceInUnit); + this.rotatedPositionOffsetVector + .set(...unrotatedPositionOffset) + .applyEuler(this.flycamRotationEuler); + const rotatedPositionOffset = this.rotatedPositionOffsetVector.toArray(); + this.planes[planeId].setPosition(originalPosition, rotatedPositionOffset); + this.planes[planeId].updateToFlycamRotation(this.flycamRotationEuler); this.quickSelectGeometry.adaptVisibilityForRendering(originalPosition, ind[2]); } else { @@ -439,7 +467,7 @@ class SceneController { const { flycam } = state; const globalPosition = getPosition(flycam); - const magIndices = getActiveMagIndicesForLayers(Store.getState()); + const magIndices = getActiveMagIndicesForLayers(state); for (const dataLayer of Model.getAllLayers()) { dataLayer.layerRenderingManager.updateDataTextures( globalPosition, @@ -473,10 +501,8 @@ class SceneController { app.vent.emit("rerender"); } - setClippingDistance(value: number): void { - // convert nm to voxel - const voxelPerNMVector = getVoxelPerUnit(Store.getState().dataset.dataSource.scale); - V3.scale(voxelPerNMVector, value, this.planeShift); + setClippingDistance(valueInUnit: number): void { + this.clippingDistanceInUnit = valueInUnit; app.vent.emit("rerender"); } diff --git a/frontend/javascripts/viewer/controller/url_manager.ts b/frontend/javascripts/viewer/controller/url_manager.ts index 454e93188cc..1e92f6db11e 100644 --- a/frontend/javascripts/viewer/controller/url_manager.ts +++ b/frontend/javascripts/viewer/controller/url_manager.ts @@ -12,7 +12,7 @@ import type { Mutable } from "types/globals"; import { validateUrlStateJSON } from "types/validation"; import type { Vector3, ViewMode } from "viewer/constants"; import constants, { ViewModeValues, MappingStatusEnum } from "viewer/constants"; -import { getPosition, getRotation } from "viewer/model/accessors/flycam_accessor"; +import { getPosition } from "viewer/model/accessors/flycam_accessor"; import { enforceSkeletonTracing } from "viewer/model/accessors/skeletontracing_accessor"; import { getMeshesForCurrentAdditionalCoordinates } from "viewer/model/accessors/volumetracing_accessor"; import { @@ -289,11 +289,11 @@ class UrlManager { const position: Vector3 = V3.floor(getPosition(state.flycam)); const { viewMode: mode } = state.temporaryConfiguration; const zoomStep = Utils.roundTo(state.flycam.zoomStep, 3); - const rotationOptional = constants.MODES_ARBITRARY.includes(mode) - ? { - rotation: Utils.map3((e) => Utils.roundTo(e, 2), getRotation(state.flycam)), - } - : {}; + const flycamRotation = Utils.map3((e) => Utils.roundTo(e, 2), state.flycam.rotation); + const rotation = { + // Keep rotation state empty if no rotation is active to have shorter url hashes. + rotation: _.isEqual(flycamRotation, [0, 0, 0]) ? undefined : flycamRotation, + }; const activeNode = state.annotation.skeleton?.activeNodeId; const activeNodeOptional = activeNode != null ? { activeNode } : {}; const stateByLayer: UrlStateByLayer = {}; @@ -380,7 +380,7 @@ class UrlManager { mode, zoomStep, additionalCoordinates: state.flycam.additionalCoordinates, - ...rotationOptional, + ...rotation, ...activeNodeOptional, ...stateByLayerOptional, }; diff --git a/frontend/javascripts/viewer/controller/viewmodes/arbitrary_controller.tsx b/frontend/javascripts/viewer/controller/viewmodes/arbitrary_controller.tsx index f138a25d71f..d0e9411d6e1 100644 --- a/frontend/javascripts/viewer/controller/viewmodes/arbitrary_controller.tsx +++ b/frontend/javascripts/viewer/controller/viewmodes/arbitrary_controller.tsx @@ -5,14 +5,18 @@ import { V3 } from "libs/mjs"; import Toast from "libs/toast"; import * as Utils from "libs/utils"; import messages from "messages"; -import * as React from "react"; +import React from "react"; import type { Point2, Vector3, ViewMode, Viewport } from "viewer/constants"; import constants, { ArbitraryViewport } from "viewer/constants"; import getSceneController from "viewer/controller/scene_controller_provider"; import TDController from "viewer/controller/td_controller"; import ArbitraryPlane from "viewer/geometries/arbitrary_plane"; import Crosshair from "viewer/geometries/crosshair"; -import { getMoveOffset3d, getPosition, getRotation } from "viewer/model/accessors/flycam_accessor"; +import { + getMoveOffset3d, + getPosition, + getRotationInDegrees, +} from "viewer/model/accessors/flycam_accessor"; import { getActiveNode, getMaxNodeId, @@ -293,7 +297,9 @@ class ArbitraryController extends React.PureComponent { return; } // implicit cast from boolean to int - Store.dispatch(setActiveNodeAction(activeNode.id + 2 * Number(nextOne) - 1)); + Store.dispatch( + setActiveNodeAction(activeNode.id + 2 * Number(nextOne) - 1, false, false, false), + ); } move(timeFactor: number): void { @@ -402,7 +408,7 @@ class ArbitraryController extends React.PureComponent { } const state = Store.getState(); const position = getPosition(state.flycam); - const rotation = getRotation(state.flycam); + const rotation = getRotationInDegrees(state.flycam); const additionalCoordinates = state.flycam.additionalCoordinates; Store.dispatch( createNodeAction( diff --git a/frontend/javascripts/viewer/controller/viewmodes/plane_controller.tsx b/frontend/javascripts/viewer/controller/viewmodes/plane_controller.tsx index 0ae334327b7..6e922405fb5 100644 --- a/frontend/javascripts/viewer/controller/viewmodes/plane_controller.tsx +++ b/frontend/javascripts/viewer/controller/viewmodes/plane_controller.tsx @@ -40,6 +40,11 @@ import { getMaximumBrushSize, } from "viewer/model/accessors/volumetracing_accessor"; import { addUserBoundingBoxAction } from "viewer/model/actions/annotation_actions"; +import { + pitchFlycamAction, + rollFlycamAction, + yawFlycamAction, +} from "viewer/model/actions/flycam_actions"; import { updateUserSettingAction } from "viewer/model/actions/settings_actions"; import { createBranchPointAction, @@ -60,7 +65,8 @@ import { createCellAction, interpolateSegmentationLayerAction, } from "viewer/model/actions/volumetracing_actions"; -import dimensions from "viewer/model/dimensions"; +import dimensions, { type DimensionIndices } from "viewer/model/dimensions"; +import Dimensions from "viewer/model/dimensions"; import { listenToStoreProperty } from "viewer/model/helpers/listener_helpers"; import { Model, api } from "viewer/singletons"; import type { BrushPresets, StoreAnnotation, WebknossosState } from "viewer/store"; @@ -89,6 +95,8 @@ function ensureNonConflictingHandlers( } } +const FIXED_ROTATION_STEP = Math.PI / 2; + const cycleTools = () => { Store.dispatch(cycleToolAction()); }; @@ -377,6 +385,29 @@ class PlaneController extends React.PureComponent { initKeyboard(): void { // avoid scrolling while pressing space + const axisIndexToRotation = { + 0: pitchFlycamAction, + 1: yawFlycamAction, + 2: rollFlycamAction, + }; + const rotateViewportAware = ( + timeFactor: number, + dimensionIndex: DimensionIndices, + oppositeDirection: boolean, + fixedStepRotation: boolean = false, + ) => { + const state = Store.getState(); + const invertingFactor = oppositeDirection ? -1 : 1; + const rotationAngle = + (fixedStepRotation + ? FIXED_ROTATION_STEP + : state.userConfiguration.rotateValue * timeFactor) * invertingFactor; + const { activeViewport } = state.viewModeData.plane; + const viewportIndices = Dimensions.getIndices(activeViewport); + const rotationAction = axisIndexToRotation[viewportIndices[dimensionIndex]]; + Store.dispatch(rotationAction(rotationAngle)); + }; + document.addEventListener("keydown", (event: KeyboardEvent) => { if ( (event.which === 32 || event.which === 18 || (event.which >= 37 && event.which <= 40)) && @@ -391,6 +422,12 @@ class PlaneController extends React.PureComponent { right: (timeFactor) => MoveHandlers.moveU(getMoveOffset(Store.getState(), timeFactor)), up: (timeFactor) => MoveHandlers.moveV(-getMoveOffset(Store.getState(), timeFactor)), down: (timeFactor) => MoveHandlers.moveV(getMoveOffset(Store.getState(), timeFactor)), + "shift + left": (timeFactor: number) => rotateViewportAware(timeFactor, 1, false), + "shift + right": (timeFactor: number) => rotateViewportAware(timeFactor, 1, true), + "shift + up": (timeFactor: number) => rotateViewportAware(timeFactor, 0, false), + "shift + down": (timeFactor: number) => rotateViewportAware(timeFactor, 0, true), + "alt + left": (timeFactor: number) => rotateViewportAware(timeFactor, 2, false), + "alt + right": (timeFactor: number) => rotateViewportAware(timeFactor, 2, true), }); const { baseControls: notLoopedKeyboardControls, @@ -424,8 +461,23 @@ class PlaneController extends React.PureComponent { delay: Store.getState().userConfiguration.keyboardDelay, }, ); + + const ignoredTimeFactor = 0; + const rotateViewportAwareFixedWithoutTiming = ( + dimensionIndex: DimensionIndices, + oppositeDirection: boolean, + ) => rotateViewportAware(ignoredTimeFactor, dimensionIndex, oppositeDirection, true); this.input.keyboardNoLoop = new InputKeyboardNoLoop( - notLoopedKeyboardControls, + { + ...notLoopedKeyboardControls, + // Directly rotate by 90 degrees. + "ctrl + shift + left": () => rotateViewportAwareFixedWithoutTiming(1, false), + "ctrl + shift + right": () => rotateViewportAwareFixedWithoutTiming(1, true), + "ctrl + shift + up": () => rotateViewportAwareFixedWithoutTiming(0, false), + "ctrl + shift + down": () => rotateViewportAwareFixedWithoutTiming(0, true), + "ctrl + alt + left": () => rotateViewportAwareFixedWithoutTiming(2, false), + "ctrl + alt + right": () => rotateViewportAwareFixedWithoutTiming(0, true), + }, {}, extendedNotLoopedKeyboardControls, keyUpControls, @@ -489,14 +541,14 @@ class PlaneController extends React.PureComponent { if (mousePosition) { const [x, y] = mousePosition; - const globalMousePosition = calculateGlobalPos(Store.getState(), { + const globalMousePositionRounded = calculateGlobalPos(Store.getState(), { x, y, - }); + }).rounded; const { cube } = segmentationLayer; const mapping = event.altKey ? cube.getMapping() : null; const hoveredId = cube.getDataValue( - globalMousePosition, + globalMousePositionRounded, additionalCoordinates, mapping, getActiveMagIndexForLayer(Store.getState(), segmentationLayer.name), diff --git a/frontend/javascripts/viewer/default_state.ts b/frontend/javascripts/viewer/default_state.ts index bb173defcef..caf89b83baf 100644 --- a/frontend/javascripts/viewer/default_state.ts +++ b/frontend/javascripts/viewer/default_state.ts @@ -71,6 +71,7 @@ const defaultState: WebknossosState = { newNodeNewTree: false, continuousNodeCreation: false, centerNewNode: true, + applyNodeRotationOnActivation: false, overrideNodeRadius: true, particleSize: 5, presetBrushSizes: null, @@ -199,6 +200,10 @@ const defaultState: WebknossosState = { spaceDirectionOrtho: [1, 1, 1], direction: [0, 0, 0], additionalCoordinates: [], + // The flycam matrix has a default rotation of 180° around the z axis (see flycam_reducer.tsx resetMatrix) which is already + // calculated out of the rotation value shown to the user and stored in this property. But as the initial matrix above + // does not have this default rotation, the correct resulting rotation value matching the identity matrix is [0,0,180]. + rotation: [0, 0, 180], }, flycamInfoCache: { maximumZoomForAllMags: {}, diff --git a/frontend/javascripts/viewer/geometries/arbitrary_plane.ts b/frontend/javascripts/viewer/geometries/arbitrary_plane.ts index 6931c2b5163..305e6b39b37 100644 --- a/frontend/javascripts/viewer/geometries/arbitrary_plane.ts +++ b/frontend/javascripts/viewer/geometries/arbitrary_plane.ts @@ -44,11 +44,6 @@ class ArbitraryPlane { this.materialFactory.stopListening(); } - setPosition = (x: number, y: number, z: number) => { - // @ts-expect-error ts-migrate(2339) FIXME: Property 'setGlobalPosition' does not exist on typ... Remove this comment to see the full error message - this.meshes.mainPlane.material.setGlobalPosition(x, y, z); - }; - addToScene(scene: THREE.Scene) { _.values(this.meshes).forEach((mesh) => { if (mesh) { diff --git a/frontend/javascripts/viewer/geometries/helper_geometries.ts b/frontend/javascripts/viewer/geometries/helper_geometries.ts index 09115f93a01..a9df66d4b26 100644 --- a/frontend/javascripts/viewer/geometries/helper_geometries.ts +++ b/frontend/javascripts/viewer/geometries/helper_geometries.ts @@ -72,6 +72,12 @@ export class ContourGeometry { } addEdgePoint(pos: Vector3) { + const pointCount = this.vertexBuffer.getLength(); + const lastPoint = this.vertexBuffer.getBuffer().subarray((pointCount - 1) * 3, pointCount * 3); + if (V3.equals(pos, lastPoint)) { + // Skip adding the point if it is the same as the last one. + return; + } this.vertexBuffer.push(pos); const startPoint = this.vertexBuffer.getBuffer().subarray(0, 3); // Setting start and end point to form the connecting line. diff --git a/frontend/javascripts/viewer/geometries/materials/plane_material_factory.ts b/frontend/javascripts/viewer/geometries/materials/plane_material_factory.ts index 8bbbfc9d821..83d06b0dea5 100644 --- a/frontend/javascripts/viewer/geometries/materials/plane_material_factory.ts +++ b/frontend/javascripts/viewer/geometries/materials/plane_material_factory.ts @@ -29,8 +29,11 @@ import { } from "viewer/model/accessors/dataset_layer_transformation_accessor"; import { getActiveMagIndicesForLayers, + getPosition, + getRotationInRadian, getUnrenderableLayerInfosForCurrentZoom, getZoomValue, + isRotated, } from "viewer/model/accessors/flycam_accessor"; import { AnnotationTool } from "viewer/model/accessors/tool_accessor"; import { isBrushTool } from "viewer/model/accessors/tool_accessor"; @@ -159,7 +162,10 @@ class PlaneMaterialFactory { is3DViewBeingRendered: { value: true, }, - globalPosition: { + // This offset represent the offset of the plane during rendering its viewport. The offset is needed to see the skeleton behind the plane + // configured by the clippingDistance setting. It is necessary to calculate the position of the data that should be rendered by subtracting + // the offset in the shader. Note, that the position offset should already be in world scale. + positionOffset: { value: new THREE.Vector3(0, 0, 0), }, zoomValue: { @@ -246,6 +252,8 @@ class PlaneMaterialFactory { value: false, }, blendMode: { value: 1.0 }, + isFlycamRotated: { value: false }, + inverseFlycamRotationMatrix: { value: new THREE.Matrix4() }, }; const activeMagIndices = getActiveMagIndicesForLayers(Store.getState()); @@ -448,9 +456,9 @@ class PlaneMaterialFactory { }; shaderEditor.addMaterial(this.shaderId, this.material); - // @ts-expect-error ts-migrate(2339) FIXME: Property 'setGlobalPosition' does not exist on typ... Remove this comment to see the full error message - this.material.setGlobalPosition = (x, y, z) => { - this.uniforms.globalPosition.value.set(x, y, z); + // @ts-expect-error ts-migrate(2339) FIXME: Property 'setPositionOffset' does not exist on typ... Remove this comment to see the full error message + this.material.setPositionOffset = (x, y, z) => { + this.uniforms.positionOffset.value.set(x, y, z); }; // @ts-expect-error ts-migrate(2339) FIXME: Property 'setUseBilinearFiltering' does not exist ... Remove this comment to see the full error message @@ -582,6 +590,29 @@ class PlaneMaterialFactory { }, true, ), + listenToStoreProperty( + (storeState) => isRotated(storeState.flycam), + (isRotated) => { + this.uniforms.isFlycamRotated.value = isRotated; + }, + ), + listenToStoreProperty( + (storeState) => getRotationInRadian(storeState.flycam), + (rotation) => { + const state = Store.getState(); + const position = getPosition(state.flycam); + + const toOrigin = new THREE.Matrix4().makeTranslation(...Utils.map3((p) => -p, position)); + const backToFlycamCenter = new THREE.Matrix4().makeTranslation(...position); + const invertRotation = new THREE.Matrix4() + .makeRotationFromEuler(new THREE.Euler(rotation[0], rotation[1], rotation[2], "ZYX")) + .invert(); + const inverseFlycamRotationMatrix = toOrigin + .multiply(invertRotation) + .multiply(backToFlycamCenter); + this.uniforms.inverseFlycamRotationMatrix.value = inverseFlycamRotationMatrix; + }, + ), ); const oldVisibilityPerLayer: Record = {}; this.storePropertyUnsubscribers.push( @@ -658,7 +689,7 @@ class PlaneMaterialFactory { const [x, y, z] = calculateGlobalPos(state, { x: globalMousePosition[0], y: globalMousePosition[1], - }); + }).rounded; this.uniforms.globalMousePosition.value.set(x, y, z); this.uniforms.isMouseInCanvas.value = true; }, @@ -1081,6 +1112,7 @@ class PlaneMaterialFactory { const textureLayerInfos = getTextureLayerInfos(); const { dataset } = Store.getState(); const voxelSizeFactor = dataset.dataSource.scale.factor; + const voxelSizeFactorInverted = V3.divide3([1, 1, 1], voxelSizeFactor); const code = getMainFragmentShader({ globalLayerCount, orderedColorLayerNames, @@ -1089,6 +1121,7 @@ class PlaneMaterialFactory { textureLayerInfos, magnificationsCount: this.getTotalMagCount(), voxelSizeFactor, + voxelSizeFactorInverted, isOrthogonal: this.isOrthogonal, tpsTransformPerLayer: this.scaledTpsInvPerLayer, }); @@ -1115,6 +1148,7 @@ class PlaneMaterialFactory { const textureLayerInfos = getTextureLayerInfos(); const { dataset } = Store.getState(); const voxelSizeFactor = dataset.dataSource.scale.factor; + const voxelSizeFactorInverted = V3.divide3([1, 1, 1], voxelSizeFactor); return getMainVertexShader({ globalLayerCount, @@ -1124,6 +1158,7 @@ class PlaneMaterialFactory { textureLayerInfos, magnificationsCount: this.getTotalMagCount(), voxelSizeFactor, + voxelSizeFactorInverted, isOrthogonal: this.isOrthogonal, tpsTransformPerLayer: this.scaledTpsInvPerLayer, }); diff --git a/frontend/javascripts/viewer/geometries/plane.ts b/frontend/javascripts/viewer/geometries/plane.ts index 6433ddd95f2..1fc8b89f090 100644 --- a/frontend/javascripts/viewer/geometries/plane.ts +++ b/frontend/javascripts/viewer/geometries/plane.ts @@ -1,3 +1,4 @@ +import { V3 } from "libs/mjs"; import _ from "lodash"; import * as THREE from "three"; import type { OrthoView, Vector3 } from "viewer/constants"; @@ -8,9 +9,7 @@ import constants, { OrthoViewValues, } from "viewer/constants"; import PlaneMaterialFactory from "viewer/geometries/materials/plane_material_factory"; -import Dimensions from "viewer/model/dimensions"; -import { getBaseVoxelFactorsInUnit } from "viewer/model/scaleinfo"; -import Store from "viewer/store"; +import { listenToStoreProperty } from "viewer/model/helpers/listener_helpers"; // A subdivision of 100 means that there will be 100 segments per axis // and thus 101 vertices per axis (i.e., the vertex shader is executed 101**2). @@ -27,20 +26,30 @@ import Store from "viewer/store"; // subdivision would probably be the next step. export const PLANE_SUBDIVISION = 100; +const DEFAULT_POSITION_OFFSET = [0, 0, 0] as Vector3; + class Plane { // This class is supposed to collect all the Geometries that belong to one single plane such as // the plane itself, its texture, borders and crosshairs. // @ts-expect-error ts-migrate(2564) FIXME: Property 'plane' has no initializer and is not def... Remove this comment to see the full error message - plane: THREE.Mesh; + plane: THREE.Mesh; planeID: OrthoView; materialFactory!: PlaneMaterialFactory; displayCrosshair: boolean; - baseScaleVector: THREE.Vector3; // @ts-expect-error ts-migrate(2564) FIXME: Property 'crosshair' has no initializer and is not... Remove this comment to see the full error message crosshair: Array; // @ts-expect-error ts-migrate(2564) FIXME: Property 'TDViewBorders' has no initializer and is... Remove this comment to see the full error message TDViewBorders: THREE.Line; lastScaleFactors: [number, number]; + // baseRotation is the base rotation the plane has in an unrotated scene. It will be applied additional to the flycams rotation. + // Different baseRotations for each of the planes ensures that the planes stay orthogonal to each other. + baseRotation: THREE.Euler; + storePropertyUnsubscribers: Array<() => void> = []; + datasetScaleFactor: Vector3 = [1, 1, 1]; + + // Properties are only created here to avoid new creating objects for each setRotation call. + baseRotationMatrix = new THREE.Matrix4(); + flycamRotationMatrix = new THREE.Matrix4(); constructor(planeID: OrthoView) { this.planeID = planeID; @@ -50,9 +59,8 @@ class Plane { // dimension with the highest mag. In all other dimensions, the plane // is smaller in voxels, so that it is squared in nm. // --> scaleInfo.baseVoxel - const baseVoxelFactors = getBaseVoxelFactorsInUnit(Store.getState().dataset.dataSource.scale); - const scaleArray = Dimensions.transDim(baseVoxelFactors, this.planeID); - this.baseScaleVector = new THREE.Vector3(...scaleArray); + this.baseRotation = new THREE.Euler(0, 0, 0); + this.bindToEvents(); this.createMeshes(); } @@ -60,6 +68,7 @@ class Plane { const pWidth = constants.VIEWPORT_WIDTH; // create plane const planeGeo = new THREE.PlaneGeometry(pWidth, pWidth, PLANE_SUBDIVISION, PLANE_SUBDIVISION); + this.materialFactory = new PlaneMaterialFactory( this.planeID, true, @@ -67,13 +76,11 @@ class Plane { ); const textureMaterial = this.materialFactory.setup().getMaterial(); this.plane = new THREE.Mesh(planeGeo, textureMaterial); - // create crosshair - const crosshairGeometries = []; - this.crosshair = new Array(2); + // Create crosshairs + this.crosshair = new Array(2); for (let i = 0; i <= 1; i++) { - crosshairGeometries.push(new THREE.BufferGeometry()); - + const crosshairGeometry = new THREE.BufferGeometry(); // biome-ignore format: don't format array const crosshairVertices = new Float32Array([ (-pWidth / 2) * i, (-pWidth / 2) * (1 - i), 0, @@ -81,13 +88,10 @@ class Plane { 25 * i, 25 * (1 - i), 0, (pWidth / 2) * i, (pWidth / 2) * (1 - i), 0, ]); + crosshairGeometry.setAttribute("position", new THREE.BufferAttribute(crosshairVertices, 3)); - crosshairGeometries[i].setAttribute( - "position", - new THREE.BufferAttribute(crosshairVertices, 3), - ); this.crosshair[i] = new THREE.LineSegments( - crosshairGeometries[i], + crosshairGeometry, this.getLineBasicMaterial(OrthoViewCrosshairColors[this.planeID][i], 1), ); // Objects are rendered according to their renderOrder (lowest to highest). @@ -96,17 +100,18 @@ class Plane { this.crosshair[i].renderOrder = 1; } - // create borders - const vertices = []; - vertices.push(new THREE.Vector3(-pWidth / 2, -pWidth / 2, 0)); - vertices.push(new THREE.Vector3(-pWidth / 2, pWidth / 2, 0)); - vertices.push(new THREE.Vector3(pWidth / 2, pWidth / 2, 0)); - vertices.push(new THREE.Vector3(pWidth / 2, -pWidth / 2, 0)); - vertices.push(new THREE.Vector3(-pWidth / 2, -pWidth / 2, 0)); - const tdViewBordersGeo = new THREE.BufferGeometry().setFromPoints(vertices); + // Create borders + const vertices = [ + new THREE.Vector3(-pWidth / 2, -pWidth / 2, 0), + new THREE.Vector3(-pWidth / 2, pWidth / 2, 0), + new THREE.Vector3(pWidth / 2, pWidth / 2, 0), + new THREE.Vector3(pWidth / 2, -pWidth / 2, 0), + new THREE.Vector3(-pWidth / 2, -pWidth / 2, 0), + ]; + const tdBorderGeometry = new THREE.BufferGeometry().setFromPoints(vertices); this.TDViewBorders = new THREE.Line( - tdViewBordersGeo, + tdBorderGeometry, this.getLineBasicMaterial(OrthoViewColors[this.planeID], 1), ); } @@ -145,45 +150,43 @@ class Plane { } this.lastScaleFactors[0] = xFactor; this.lastScaleFactors[1] = yFactor; - - const scaleVec = new THREE.Vector3().multiplyVectors( - new THREE.Vector3(xFactor, yFactor, 1), - this.baseScaleVector, - ); - this.plane.scale.copy(scaleVec); - this.TDViewBorders.scale.copy(scaleVec); - this.crosshair[0].scale.copy(scaleVec); - this.crosshair[1].scale.copy(scaleVec); + // Account for the dataset scale to match one world space coordinate to one dataset scale unit. + const scaleVector: Vector3 = V3.multiply([xFactor, yFactor, 1], this.datasetScaleFactor); + this.getMeshes().map((mesh) => mesh.scale.set(...scaleVector)); } - setRotation = (rotVec: THREE.Euler): void => { - [this.plane, this.TDViewBorders, this.crosshair[0], this.crosshair[1]].map((mesh) => - mesh.setRotationFromEuler(rotVec), - ); + setBaseRotation = (rotVec: THREE.Euler): void => { + this.baseRotation.copy(rotVec); + this.baseRotationMatrix.makeRotationFromEuler(this.baseRotation); + }; + + updateToFlycamRotation = (flycamRotationVec: THREE.Euler): void => { + // rotVec must be in "ZYX" order as this is how the flycam operates (see flycam_reducer setRotationReducer) + this.flycamRotationMatrix.makeRotationFromEuler(flycamRotationVec); + const combinedMatrix = this.flycamRotationMatrix.multiply(this.baseRotationMatrix); + this.getMeshes().map((mesh) => mesh.setRotationFromMatrix(combinedMatrix)); }; // In case the plane's position was offset to make geometries // on the plane visible (by moving the plane to the back), one can - // additionally pass the originalPosition (which is necessary for the + // additionally pass the offset of the position (which is necessary for the // shader) - setPosition = (pos: Vector3, originalPosition?: Vector3): void => { - const [x, y, z] = pos; - this.TDViewBorders.position.set(x, y, z); - this.crosshair[0].position.set(x, y, z); - this.crosshair[1].position.set(x, y, z); - this.plane.position.set(x, y, z); - - if (originalPosition == null) { - // @ts-expect-error ts-migrate(2339) FIXME: Property 'setGlobalPosition' does not exist on typ... Remove this comment to see the full error message - this.plane.material.setGlobalPosition(x, y, z); - } else { - // @ts-expect-error ts-migrate(2339) FIXME: Property 'setGlobalPosition' does not exist on typ... Remove this comment to see the full error message - this.plane.material.setGlobalPosition( - originalPosition[0], - originalPosition[1], - originalPosition[2], - ); - } + setPosition = ( + originalPosition: Vector3, + positionOffset: Vector3 = DEFAULT_POSITION_OFFSET, + ): void => { + // The world scaling by the dataset scale factor is inverted by the scene group + + // containing all planes to avoid sheering in anisotropic scaled datasets. + // Thus, this scale needs to be applied manually to the position here. + const scaledPosition = V3.multiply(originalPosition, this.datasetScaleFactor); + // The offset is in world space already so no scaling is necessary. + const offsetPosition = V3.add(scaledPosition, positionOffset); + this.TDViewBorders.position.set(...offsetPosition); + this.crosshair[0].position.set(...offsetPosition); + this.crosshair[1].position.set(...offsetPosition); + this.plane.position.set(...offsetPosition); + this.plane.material.setPositionOffset(...positionOffset); }; setVisible = (isVisible: boolean, isDataVisible?: boolean): void => { @@ -195,12 +198,22 @@ class Plane { getMeshes = () => [this.plane, this.TDViewBorders, this.crosshair[0], this.crosshair[1]]; setLinearInterpolationEnabled = (enabled: boolean) => { - // @ts-expect-error ts-migrate(2339) FIXME: Property 'setUseBilinearFiltering' does not exist ... Remove this comment to see the full error message this.plane.material.setUseBilinearFiltering(enabled); }; destroy() { this.materialFactory.destroy(); + this.storePropertyUnsubscribers.forEach((f) => f()); + this.storePropertyUnsubscribers = []; + } + + bindToEvents(): void { + this.storePropertyUnsubscribers = [ + listenToStoreProperty( + (storeState) => storeState.dataset.dataSource.scale.factor, + (scaleFactor) => (this.datasetScaleFactor = scaleFactor), + ), + ]; } } diff --git a/frontend/javascripts/viewer/model/accessors/disabled_tool_accessor.ts b/frontend/javascripts/viewer/model/accessors/disabled_tool_accessor.ts index db74e6c384b..c9d6f0cc38d 100644 --- a/frontend/javascripts/viewer/model/accessors/disabled_tool_accessor.ts +++ b/frontend/javascripts/viewer/model/accessors/disabled_tool_accessor.ts @@ -7,7 +7,7 @@ import memoizeOne from "memoize-one"; import type { APIOrganization, APIUser } from "types/api_types"; import { IdentityTransform } from "viewer/constants"; import { getVisibleSegmentationLayer } from "viewer/model/accessors/dataset_accessor"; -import { isMagRestrictionViolated } from "viewer/model/accessors/flycam_accessor"; +import { isMagRestrictionViolated, isRotated } from "viewer/model/accessors/flycam_accessor"; import type { WebknossosState } from "viewer/store"; import { reuseInstanceOnEquality } from "./accessor_helpers"; import { getTransformsPerLayer } from "./dataset_layer_transformation_accessor"; @@ -41,6 +41,9 @@ const noSkeletonsExplanation = const disabledSkeletonExplanation = "Currently all trees are invisible. To use this tool, make the skeleton layer visible by toggling the button in the left sidebar."; +const rotationActiveDisabledExplanation = + "The tool is disabled because you are currently viewing the dataset rotated. Please reset the rotation to 0,0,0 to be able to use this tool."; + const getExplanationForDisabledVolume = ( isSegmentationTracingVisible: boolean, isInMergerMode: boolean, @@ -49,11 +52,16 @@ const getExplanationForDisabledVolume = ( isEditableMappingActive: boolean, isSegmentationTracingTransformed: boolean, isJSONMappingActive: boolean, + isFlycamRotated: boolean, ) => { if (!isSegmentationTracingVisible) { return "Volume annotation is disabled since no segmentation tracing layer is enabled. Enable one in the left settings sidebar or make a segmentation layer editable via the lock icon."; } + if (isFlycamRotated) { + return rotationActiveDisabledExplanation; + } + if (isZoomInvalidForTracing) { return "Volume annotation is disabled since the current zoom value is not in the required range. Please adjust the zoom level."; } @@ -83,9 +91,21 @@ const getExplanationForDisabledVolume = ( const ALWAYS_ENABLED_TOOL_INFOS = { [AnnotationTool.MOVE.id]: NOT_DISABLED_INFO, [AnnotationTool.LINE_MEASUREMENT.id]: NOT_DISABLED_INFO, - [AnnotationTool.AREA_MEASUREMENT.id]: NOT_DISABLED_INFO, }; +function _getAreaMeasurementToolInfo(isFlycamRotated: boolean) { + return { + [AnnotationTool.AREA_MEASUREMENT.id]: isFlycamRotated + ? { + isDisabled: true, + explanation: rotationActiveDisabledExplanation, + } + : NOT_DISABLED_INFO, + }; +} + +const getAreaMeasurementToolInfo = memoizeOne(_getAreaMeasurementToolInfo); + function _getSkeletonToolInfo( hasSkeleton: boolean, isSkeletonLayerTransformed: boolean, @@ -125,7 +145,19 @@ function _getSkeletonToolInfo( } const getSkeletonToolInfo = memoizeOne(_getSkeletonToolInfo); -function _getBoundingBoxToolInfo(hasSkeleton: boolean, areGeometriesTransformed: boolean) { +function _getBoundingBoxToolInfo( + hasSkeleton: boolean, + areGeometriesTransformed: boolean, + isFlycamRotated: boolean, +) { + if (isFlycamRotated) { + return { + [AnnotationTool.BOUNDING_BOX.id]: { + isDisabled: true, + explanation: rotationActiveDisabledExplanation, + }, + }; + } if (areGeometriesTransformed) { return { [AnnotationTool.BOUNDING_BOX.id]: { @@ -155,6 +187,7 @@ function _getDisabledInfoWhenVolumeIsDisabled( isSegmentationTracingTransformed: boolean, isVolumeDisabled: boolean, isJSONMappingActive: boolean, + isFlycamRotated: boolean, ) { const genericDisabledExplanation = getExplanationForDisabledVolume( isSegmentationTracingVisible, @@ -164,6 +197,7 @@ function _getDisabledInfoWhenVolumeIsDisabled( isEditableMappingActive, isSegmentationTracingTransformed, isJSONMappingActive, + isFlycamRotated, ); const disabledInfo = { @@ -293,6 +327,7 @@ function getDisabledVolumeInfo(state: WebknossosState) { const { activeMappingByLayer } = state.temporaryConfiguration; const isZoomInvalidForTracing = isMagRestrictionViolated(state); const hasVolume = state.annotation.volumes.length > 0; + const isFlycamRotated = isRotated(state.flycam); const hasSkeleton = state.annotation.skeleton != null; const segmentationTracingLayer = getActiveSegmentationTracing(state); const labeledMag = getRenderableMagForSegmentationTracing(state, segmentationTracingLayer)?.mag; @@ -329,7 +364,7 @@ function getDisabledVolumeInfo(state: WebknossosState) { (segmentationTracingLayer?.mappingIsLocked && !segmentationTracingLayer?.hasEditableMapping) ?? false; - return isVolumeDisabled || isEditableMappingActive + return isVolumeDisabled || isEditableMappingActive || isFlycamRotated ? // All segmentation-related tools are disabled. getDisabledInfoWhenVolumeIsDisabled( isSegmentationTracingVisible, @@ -340,6 +375,7 @@ function getDisabledVolumeInfo(state: WebknossosState) { isSegmentationTracingTransformed, isVolumeDisabled, isJSONMappingActive, + isFlycamRotated, ) : // Volume tools are not ALL disabled, but some of them might be. getVolumeDisabledWhenVolumeIsEnabled( @@ -360,17 +396,24 @@ const _getDisabledInfoForTools = ( ): Record => { const { annotation } = state; const hasSkeleton = annotation.skeleton != null; + const isFlycamRotated = isRotated(state.flycam); const geometriesTransformed = areGeometriesTransformed(state); + const areaMeasurementToolInfo = getAreaMeasurementToolInfo(isFlycamRotated); const skeletonToolInfo = getSkeletonToolInfo( hasSkeleton, geometriesTransformed, isSkeletonLayerVisible(annotation), ); - const boundingBoxInfo = getBoundingBoxToolInfo(hasSkeleton, geometriesTransformed); + const boundingBoxInfo = getBoundingBoxToolInfo( + hasSkeleton, + geometriesTransformed, + isFlycamRotated, + ); const disabledVolumeInfo = getDisabledVolumeInfo(state); return { ...ALWAYS_ENABLED_TOOL_INFOS, + ...areaMeasurementToolInfo, ...skeletonToolInfo, ...disabledVolumeInfo, ...boundingBoxInfo, diff --git a/frontend/javascripts/viewer/model/accessors/flycam_accessor.ts b/frontend/javascripts/viewer/model/accessors/flycam_accessor.ts index 3080e64129f..7698d6c3baa 100644 --- a/frontend/javascripts/viewer/model/accessors/flycam_accessor.ts +++ b/frontend/javascripts/viewer/model/accessors/flycam_accessor.ts @@ -34,6 +34,10 @@ import * as scaleInfo from "viewer/model/scaleinfo"; import { getBaseVoxelInUnit } from "viewer/model/scaleinfo"; import type { DataLayerType, Flycam, LoadingStrategy, WebknossosState } from "viewer/store"; import type { SmallerOrHigherInfo } from "../helpers/mag_info"; +import { + eulerAngleToReducerInternalMatrix, + reducerInternalMatrixToEulerAngle, +} from "../helpers/rotation_helpers"; import { type Transform, chainTransforms, @@ -297,18 +301,66 @@ function _getFlooredPosition(flycam: Flycam): Vector3 { return map3((x) => Math.floor(x), _getPosition(flycam)); } -function _getRotation(flycam: Flycam): Vector3 { +// Avoiding object creation with each call. +const flycamMatrixObject = new THREE.Matrix4(); + +// Returns the current rotation of the flycam in radians as an euler xyz tuple. +// As the order in which the angles are applied is zyx (see flycam_reducer setRotationReducer), +// this order must be followed when this euler angle is applied to 3d rotation computations. +// But when calculating with +function _getRotationInRadianFromMatrix(flycamMatrix: Matrix4x4, invertZ: boolean = true): Vector3 { + // Somehow z rotation is inverted but the others are not. + const zInvertFactor = invertZ ? -1 : 1; const object = new THREE.Object3D(); - const matrix = new THREE.Matrix4().fromArray(flycam.currentMatrix).transpose(); - object.applyMatrix4(matrix); - const rotation: Vector3 = [object.rotation.x, object.rotation.y, object.rotation.z - Math.PI]; + flycamMatrixObject.fromArray(flycamMatrix).transpose(); + object.applyMatrix4(flycamMatrixObject); + return [ + mod(object.rotation.x, Math.PI * 2), + mod(object.rotation.y, Math.PI * 2), + mod((object.rotation.z - Math.PI) * zInvertFactor, Math.PI * 2), + ]; +} + +export const getRotationInRadianFromMatrix = memoizeOne(_getRotationInRadianFromMatrix); + +function _getRotationInRadian(flycam: Flycam, invertZ: boolean = true): Vector3 { + return getRotationInRadianFromMatrix(flycam.currentMatrix, invertZ); +} + +function _getRotationInDegrees(flycamOrMatrix: Flycam | Matrix4x4): Vector3 { + const matrix = Array.isArray(flycamOrMatrix) + ? flycamOrMatrix + : (flycamOrMatrix as Flycam).currentMatrix; + const rotationInRadian = getRotationInRadianFromMatrix(matrix, false); + // Modulo operation not needed as already done in getRotationInRadian. return [ - mod((180 / Math.PI) * rotation[0], 360), - mod((180 / Math.PI) * rotation[1], 360), - mod((180 / Math.PI) * rotation[2], 360), + (180 / Math.PI) * rotationInRadian[0], + (180 / Math.PI) * rotationInRadian[1], + (180 / Math.PI) * rotationInRadian[2], ]; } +function _isRotated(flycam: Flycam): boolean { + return !V3.equals(getRotationInRadian(flycam), [0, 0, 0]); +} + +function _getFlycamRotationWithAppendedRotation( + flycam: Flycam, + rotationToAppend: THREE.Euler, +): Vector3 { + const flycamRotation = getRotationInRadian(flycam, false); + + // Perform same operations as the flycam reducer does. First default 180° around z. + let rotFlycamMatrix = eulerAngleToReducerInternalMatrix(flycamRotation); + // Apply rotation + rotFlycamMatrix = rotFlycamMatrix.multiply( + new THREE.Matrix4().makeRotationFromEuler(rotationToAppend), + ); + const rotationInRadian = reducerInternalMatrixToEulerAngle(rotFlycamMatrix); + const rotationInDegree = map3(THREE.MathUtils.radToDeg, rotationInRadian); + return rotationInDegree; +} + function _getZoomedMatrix(flycam: Flycam): Matrix4x4 { return M4x4.scale1(flycam.zoomStep, flycam.currentMatrix); } @@ -317,7 +369,12 @@ export const getUp = memoizeOne(_getUp); export const getLeft = memoizeOne(_getLeft); export const getPosition = memoizeOne(_getPosition); export const getFlooredPosition = memoizeOne(_getFlooredPosition); -export const getRotation = memoizeOne(_getRotation); +export const getRotationInRadian = memoizeOne(_getRotationInRadian); +export const getRotationInDegrees = memoizeOne(_getRotationInDegrees); +export const getFlycamRotationWithAppendedRotation = memoizeOne( + _getFlycamRotationWithAppendedRotation, +); +export const isRotated = memoizeOne(_isRotated); export const getZoomedMatrix = memoizeOne(_getZoomedMatrix); function _getActiveMagIndicesForLayers(state: WebknossosState): { [layerName: string]: number } { @@ -494,19 +551,7 @@ export function getPlaneExtentInVoxel( const { width, height } = rects[planeID]; return [width * zoomStep, height * zoomStep]; } -export function getRotationOrtho(planeId: OrthoView): Vector3 { - switch (planeId) { - case OrthoViews.PLANE_YZ: - return [0, 270, 0]; - - case OrthoViews.PLANE_XZ: - return [90, 0, 0]; - case OrthoViews.PLANE_XY: - default: - return [0, 0, 0]; - } -} export type Area = { left: number; top: number; diff --git a/frontend/javascripts/viewer/model/accessors/view_mode_accessor.ts b/frontend/javascripts/viewer/model/accessors/view_mode_accessor.ts index 294de5c2118..914d52a710b 100644 --- a/frontend/javascripts/viewer/model/accessors/view_mode_accessor.ts +++ b/frontend/javascripts/viewer/model/accessors/view_mode_accessor.ts @@ -1,6 +1,7 @@ import { V3 } from "libs/mjs"; import _ from "lodash"; import memoizeOne from "memoize-one"; +import * as THREE from "three"; import type { OrthoView, OrthoViewExtents, @@ -17,9 +18,10 @@ import constants, { OrthoViewValuesWithoutTDView, } from "viewer/constants"; import { reuseInstanceOnEquality } from "viewer/model/accessors/accessor_helpers"; -import { getPosition } from "viewer/model/accessors/flycam_accessor"; +import { getPosition, getRotationInRadian } from "viewer/model/accessors/flycam_accessor"; import { getBaseVoxelFactorsInUnit } from "viewer/model/scaleinfo"; import type { Flycam, WebknossosState } from "viewer/store"; +import Dimensions from "../dimensions"; export function getTDViewportSize(state: WebknossosState): [number, number] { const camera = state.viewModeData.plane.tdCamera; @@ -90,45 +92,66 @@ export function getViewportScale(state: WebknossosState, viewport: Viewport): [n return [xScale, yScale]; } +export type PositionWithRounding = { rounded: Vector3; floating: Vector3 }; + +// Avoiding object creation with each call. +const flycamRotationEuler = new THREE.Euler(); +const flycamRotationMatrix = new THREE.Matrix4(); +const flycamPositionMatrix = new THREE.Matrix4(); +const rotatedDiff = new THREE.Vector3(); +const planeRatioVector = new THREE.Vector3(); + function _calculateMaybeGlobalPos( state: WebknossosState, clickPos: Point2, - planeId?: OrthoView | null | undefined, -): Vector3 | null | undefined { - let position: Vector3; - planeId = planeId || state.viewModeData.plane.activeViewport; + planeIdOpt?: OrthoView | null | undefined, +): PositionWithRounding | null | undefined { + let roundedPosition: Vector3, floatingPosition: Vector3; + const planeId = planeIdOpt || state.viewModeData.plane.activeViewport; const curGlobalPos = getPosition(state.flycam); + const flycamRotation = getRotationInRadian(state.flycam); const planeRatio = getBaseVoxelFactorsInUnit(state.dataset.dataSource.scale); const { width, height } = getInputCatcherRect(state, planeId); // Subtract clickPos from only half of the viewport extent as // the center of the viewport / the flycam position is used as a reference point. - const diffX = (width / 2 - clickPos.x) * state.flycam.zoomStep; - const diffY = (height / 2 - clickPos.y) * state.flycam.zoomStep; + const diffU = (width / 2 - clickPos.x) * state.flycam.zoomStep; + const diffV = (height / 2 - clickPos.y) * state.flycam.zoomStep; + const diffUvw = [diffU, diffV, 0] as Vector3; + const diffXyz = Dimensions.transDim(diffUvw, planeId); + flycamRotationMatrix.makeRotationFromEuler(flycamRotationEuler.set(...flycamRotation, "ZYX")); + flycamPositionMatrix.makeTranslation(...curGlobalPos); + rotatedDiff.set(...diffXyz).applyMatrix4(flycamRotationMatrix); + const scaledRotatedPosition = rotatedDiff + .multiply(planeRatioVector.set(...planeRatio)) + .multiplyScalar(-1); + + const globalFloatingPosition = scaledRotatedPosition.applyMatrix4(flycamPositionMatrix); + floatingPosition = globalFloatingPosition.toArray() as Vector3; switch (planeId) { case OrthoViews.PLANE_XY: { - position = [ - Math.round(curGlobalPos[0] - diffX * planeRatio[0]), - Math.round(curGlobalPos[1] - diffY * planeRatio[1]), - Math.floor(curGlobalPos[2]), + roundedPosition = [ + Math.round(globalFloatingPosition.x), + Math.round(globalFloatingPosition.y), + Math.floor(globalFloatingPosition.z), ]; break; } case OrthoViews.PLANE_YZ: { - position = [ - Math.floor(curGlobalPos[0]), - Math.round(curGlobalPos[1] - diffY * planeRatio[1]), - Math.round(curGlobalPos[2] - diffX * planeRatio[2]), + roundedPosition = [ + Math.floor(globalFloatingPosition.x), + Math.round(globalFloatingPosition.y), + Math.round(globalFloatingPosition.z), ]; break; } case OrthoViews.PLANE_XZ: { - position = [ - Math.round(curGlobalPos[0] - diffX * planeRatio[0]), - Math.floor(curGlobalPos[1]), - Math.round(curGlobalPos[2] - diffY * planeRatio[2]), + roundedPosition = [ + Math.round(globalFloatingPosition.x), + Math.floor(globalFloatingPosition.y), + Math.round(globalFloatingPosition.z), ]; break; } @@ -137,57 +160,83 @@ function _calculateMaybeGlobalPos( return null; } - return position; + return { rounded: roundedPosition, floating: floatingPosition }; +} + +// This function inverts parts of the _calculateMaybeGlobalPos function. +// It takes a global position and calculates a screen space vector relative to the flycam position for it. +// The result it like the input of position of _calculateMaybeGlobalPos but as a 3D vector from which the +// viewport dependant coordinates need to be extracted (xy -> xy, yz -> zy, xz -> xz). +function _calculateInViewportPos( + globalPosition: Vector3, + flycamPosition: Vector3, + flycamRotationInRadian: Vector3, + planeRatio: Vector3, + zoomStep: number, +): THREE.Vector3 { + // Difference in world space + const positionDiff = new THREE.Vector3(...V3.sub(globalPosition, flycamPosition)); + + // Inverse rotate the world difference vector into local plane-aligned space + const inverseRotationMatrix = new THREE.Matrix4() + .makeRotationFromEuler(new THREE.Euler(...flycamRotationInRadian, "ZYX")) + .invert(); + + // Unscale from voxel ratio (undo voxel scaling) + const posInScreenSpaceScaling = positionDiff.divide(new THREE.Vector3(...planeRatio)); + const rotatedIntoScreenSpace = posInScreenSpaceScaling.applyMatrix4(inverseRotationMatrix); + const unzoomedPosition = rotatedIntoScreenSpace.multiplyScalar(1 / zoomStep); + return unzoomedPosition; } function _calculateMaybePlaneScreenPos( state: WebknossosState, globalPosition: Vector3, - planeId?: OrthoView | null | undefined, -): Point2 | null | undefined { - // This method does the reverse of _calculateMaybeGlobalPos. It takes a global position - // and calculates the corresponding screen position in the given plane. - // This is achieved by reversing the calculations in _calculateMaybeGlobalPos. - let point: Point2; - planeId = planeId || state.viewModeData.plane.activeViewport; - const navbarHeight = state.uiInformation.navbarHeight; - const curGlobalPos = getPosition(state.flycam); - const planeRatio = getBaseVoxelFactorsInUnit(state.dataset.dataSource.scale); + planeId: OrthoView, +): Vector2 | null | undefined { + // This method now accounts for flycam rotation, matching the forward transformation + let point: Vector2; + const { width, height, top, left } = getInputCatcherRect(state, planeId); - const positionDiff = V3.sub(globalPosition, curGlobalPos); + + const flycamPosition = getPosition(state.flycam); + const flycamRotation = getRotationInRadian(state.flycam); + const planeRatio = getBaseVoxelFactorsInUnit(state.dataset.dataSource.scale); + const navbarHeight = state.uiInformation.navbarHeight; + + const positionInViewportPerspective = calculateInViewportPos( + globalPosition, + flycamPosition, + flycamRotation, + planeRatio, + state.flycam.zoomStep, + ); + + // Get plane-aligned screen-space coordinates (u/v) switch (planeId) { case OrthoViews.PLANE_XY: { - point = { - x: positionDiff[0] / state.flycam.zoomStep / planeRatio[0], - y: positionDiff[1] / state.flycam.zoomStep / planeRatio[1], - }; + point = [positionInViewportPerspective.x, positionInViewportPerspective.y]; break; } case OrthoViews.PLANE_YZ: { - point = { - x: positionDiff[2] / state.flycam.zoomStep / planeRatio[2], - y: positionDiff[1] / state.flycam.zoomStep / planeRatio[1], - }; + point = [positionInViewportPerspective.z, positionInViewportPerspective.y]; break; } case OrthoViews.PLANE_XZ: { - point = { - x: positionDiff[0] / state.flycam.zoomStep / planeRatio[0], - y: positionDiff[2] / state.flycam.zoomStep / planeRatio[2], - }; + point = [positionInViewportPerspective.x, positionInViewportPerspective.z]; break; } default: return null; } - point.x += width / 2 + left; - point.y += height / 2 + top + navbarHeight; - point.x = Math.round(point.x); - point.y = Math.round(point.y); + point = [ + Math.round(point[0] + width / 2 + left), + Math.round(point[1] + height / 2 + top + navbarHeight), + ]; return point; } @@ -229,15 +278,15 @@ function _calculateGlobalPos( state: WebknossosState, clickPos: Point2, planeId?: OrthoView | null | undefined, -): Vector3 { - const position = _calculateMaybeGlobalPos(state, clickPos, planeId); +): PositionWithRounding { + const positions = _calculateMaybeGlobalPos(state, clickPos, planeId); - if (!position) { + if (!positions || !positions.rounded) { console.error("Trying to calculate the global position, but no data viewport is active."); - return [0, 0, 0]; + return { rounded: [0, 0, 0], floating: [0, 0, 0] }; } - return position; + return positions; } function _calculateGlobalDelta( @@ -289,6 +338,7 @@ export const calculateMaybeGlobalPos = reuseInstanceOnEquality(_calculateMaybeGl export const calculateGlobalPos = reuseInstanceOnEquality(_calculateGlobalPos); export const calculateGlobalDelta = reuseInstanceOnEquality(_calculateGlobalDelta); export const calculateMaybePlaneScreenPos = reuseInstanceOnEquality(_calculateMaybePlaneScreenPos); +export const calculateInViewportPos = reuseInstanceOnEquality(_calculateInViewportPos); export function getViewMode(state: WebknossosState): ViewMode { return state.temporaryConfiguration.viewMode; } diff --git a/frontend/javascripts/viewer/model/actions/flycam_actions.ts b/frontend/javascripts/viewer/model/actions/flycam_actions.ts index 9d386806ab2..a48e0a0e9ff 100644 --- a/frontend/javascripts/viewer/model/actions/flycam_actions.ts +++ b/frontend/javascripts/viewer/model/actions/flycam_actions.ts @@ -12,6 +12,7 @@ type SetDirectionAction = ReturnType; type MoveFlycamOrthoAction = ReturnType; type MovePlaneFlycamOrthoAction = ReturnType; type MoveFlycamAction = ReturnType; +type MoveFlycamAbsoluteAction = ReturnType; type YawFlycamAction = ReturnType; type RollFlycamAction = ReturnType; type PitchFlycamAction = ReturnType; @@ -27,6 +28,7 @@ export type FlycamAction = | SetRotationAction | SetDirectionAction | MoveFlycamAction + | MoveFlycamAbsoluteAction | MoveFlycamOrthoAction | MovePlaneFlycamOrthoAction | YawFlycamAction @@ -45,6 +47,7 @@ export const FlycamActions = [ "MOVE_FLYCAM_ORTHO", "MOVE_PLANE_FLYCAM_ORTHO", "MOVE_FLYCAM", + "MOVE_FLYCAM_ABSOLUTE", "YAW_FLYCAM", "ROLL_FLYCAM", "PITCH_FLYCAM", @@ -123,6 +126,13 @@ export const moveFlycamAction = (vector: Vector3) => vector, }) as const; +// Use when a translation should be done directly to the position and rotation, zoom and more should be ignored. +export const moveFlycamAbsoluteAction = (vector: Vector3) => + ({ + type: "MOVE_FLYCAM_ABSOLUTE", + vector, + }) as const; + export const yawFlycamAction = (angle: number, regardDistance: boolean = false) => ({ type: "YAW_FLYCAM", diff --git a/frontend/javascripts/viewer/model/actions/skeletontracing_actions.tsx b/frontend/javascripts/viewer/model/actions/skeletontracing_actions.tsx index dac947792ff..6b044c1fb20 100644 --- a/frontend/javascripts/viewer/model/actions/skeletontracing_actions.tsx +++ b/frontend/javascripts/viewer/model/actions/skeletontracing_actions.tsx @@ -241,12 +241,14 @@ export const setActiveNodeAction = ( nodeId: number | null, suppressAnimation: boolean = false, suppressCentering: boolean = false, + suppressRotation?: boolean, ) => ({ type: "SET_ACTIVE_NODE", nodeId, suppressAnimation, suppressCentering, + suppressRotation, }) as const; export const centerActiveNodeAction = (suppressAnimation: boolean = false) => diff --git a/frontend/javascripts/viewer/model/helpers/action_logger_middleware.ts b/frontend/javascripts/viewer/model/helpers/action_logger_middleware.ts index 188930c22dc..129ea49a6c3 100644 --- a/frontend/javascripts/viewer/model/helpers/action_logger_middleware.ts +++ b/frontend/javascripts/viewer/model/helpers/action_logger_middleware.ts @@ -13,6 +13,7 @@ let lastActionCount: number = 0; const actionBlacklist = [ "ADD_TO_LAYER", "MOVE_FLYCAM", + "MOVE_FLYCAM_ABSOLUTE", "MOVE_FLYCAM_ORTHO", "MOVE_PLANE_FLYCAM_ORTHO", "PUSH_SAVE_QUEUE_TRANSACTION", diff --git a/frontend/javascripts/viewer/model/helpers/nml_helpers.ts b/frontend/javascripts/viewer/model/helpers/nml_helpers.ts index 3b6f3656aaa..557d1ea3d40 100644 --- a/frontend/javascripts/viewer/model/helpers/nml_helpers.ts +++ b/frontend/javascripts/viewer/model/helpers/nml_helpers.ts @@ -11,7 +11,7 @@ import type { AdditionalCoordinate } from "types/api_types"; import type { BoundingBoxMinMaxType } from "types/bounding_box"; import { IdentityTransform, type TreeType, TreeTypeEnum, type Vector3 } from "viewer/constants"; import Constants from "viewer/constants"; -import { getPosition, getRotation } from "viewer/model/accessors/flycam_accessor"; +import { getPosition, getRotationInDegrees } from "viewer/model/accessors/flycam_accessor"; import EdgeCollection from "viewer/model/edge_collection"; import { getMaximumGroupId, @@ -244,7 +244,7 @@ function serializeParameters( const editPositionAdditionalCoordinates = state.flycam.additionalCoordinates; const { additionalAxes } = skeletonTracing; - const editRotation = getRotation(state.flycam); + const editRotation = getRotationInDegrees(state.flycam); const userBBoxes = skeletonTracing.userBoundingBoxes; const taskBB = skeletonTracing.boundingBox; diff --git a/frontend/javascripts/viewer/model/helpers/rotation_helpers.ts b/frontend/javascripts/viewer/model/helpers/rotation_helpers.ts new file mode 100644 index 00000000000..306afa035bb --- /dev/null +++ b/frontend/javascripts/viewer/model/helpers/rotation_helpers.ts @@ -0,0 +1,46 @@ +import { V3 } from "libs/mjs"; +import { mod } from "libs/utils"; +import * as THREE from "three"; +import type { Vector3 } from "viewer/constants"; + +// Pre definitions to avoid redundant object creation. +const matrix = new THREE.Matrix4(); +const euler = new THREE.Euler(); +const invertedEulerMatrix = new THREE.Matrix4(); + +// This function performs the same operations as done in the flycam reducer for the setRotation action. +// When rotation calculation are needed in the flycam matrix space, this function can be used to +// first convert the angle into the quirky way the flycam matrix does rotations. +// After the rotation calculation is done, the companion function reducerInternalMatrixToEulerAngle +// should be used to transform the result back. +// For some more info look at +// https://www.notion.so/scalableminds/3D-Rotations-3D-Scene-210b51644c6380c2a4a6f5f3c069738a?source=copy_link#22bb51644c6380cf8302fb8f6749ae1d. +export function eulerAngleToReducerInternalMatrix(angleInRadian: Vector3): THREE.Matrix4 { + // Perform same operations as the flycam reducer does. First default 180° around z. + let matrixLikeInReducer = matrix.makeRotationZ(Math.PI); + // Invert angle and interpret as ZYX order + const invertedEuler = euler.set(...V3.scale(angleInRadian, -1), "ZYX"); + // Apply inverted ZYX euler. + matrixLikeInReducer = matrixLikeInReducer.multiply( + invertedEulerMatrix.makeRotationFromEuler(invertedEuler), + ); + return matrixLikeInReducer; +} + +// Pre definitions to avoid redundant object creation. +const rotationFromMatrix = new THREE.Euler(); + +// The companion function of eulerAngleToReducerInternalMatrix converting a rotation back from the flycam reducer space. +// The output is in radian and should be interpreted as if in ZYX order. +// Note: The matrix must be a rotation only matrix. +export function reducerInternalMatrixToEulerAngle(matrixInReducerFormat: THREE.Matrix4): Vector3 { + const localRotationFromMatrix = rotationFromMatrix.setFromRotationMatrix( + matrixInReducerFormat.clone().transpose(), + "XYZ", + ); + return [ + mod(localRotationFromMatrix.x, 2 * Math.PI), + mod(localRotationFromMatrix.y, 2 * Math.PI), + mod(localRotationFromMatrix.z - Math.PI, 2 * Math.PI), + ]; +} diff --git a/frontend/javascripts/viewer/model/reducers/flycam_reducer.ts b/frontend/javascripts/viewer/model/reducers/flycam_reducer.ts index d8f85145337..627ab7aa19b 100644 --- a/frontend/javascripts/viewer/model/reducers/flycam_reducer.ts +++ b/frontend/javascripts/viewer/model/reducers/flycam_reducer.ts @@ -1,11 +1,14 @@ import update from "immutability-helper"; import type { Matrix4x4 } from "libs/mjs"; -import { M4x4 } from "libs/mjs"; +import { M4x4, V3 } from "libs/mjs"; import * as Utils from "libs/utils"; import _ from "lodash"; +import * as THREE from "three"; import type { Vector3 } from "viewer/constants"; import { ZOOM_STEP_INTERVAL, + getRotationInDegrees, + getRotationInRadian, getValidZoomRangeForUser, } from "viewer/model/accessors/flycam_accessor"; import type { Action } from "viewer/model/actions/actions"; @@ -39,6 +42,11 @@ export function rotateOnAxis(currentMatrix: Matrix4x4, angle: number, axis: Vect return M4x4.rotate(angle, axis, currentMatrix, []); } +// Avoid creating new THREE object for some actions. +const flycamRotationEuler = new THREE.Euler(); +const flycamRotationMatrix = new THREE.Matrix4(); +const deltaInWorld = new THREE.Vector3(); + function rotateOnAxisWithDistance( zoomStep: number, distance: number, @@ -53,6 +61,11 @@ function rotateOnAxisWithDistance( return M4x4.translate(distanceVecPositive, matrix, []); } +function keepRotationInBounds(rotation: Vector3): Vector3 { + const rotationInBounds = Utils.map3((v) => Utils.mod(Math.round(v), 360), rotation); + return rotationInBounds; +} + function rotateReducer( state: WebknossosState, angle: number, @@ -64,27 +77,24 @@ function rotateReducer( } const { flycam } = state; - - if (regardDistance) { - return update(state, { - flycam: { - currentMatrix: { - $set: rotateOnAxisWithDistance( - flycam.zoomStep, - state.userConfiguration.sphericalCapRadius, - flycam.currentMatrix, - angle, - axis, - ), - }, - }, - }); - } + const updatedMatrix = regardDistance + ? rotateOnAxisWithDistance( + flycam.zoomStep, + state.userConfiguration.sphericalCapRadius, + flycam.currentMatrix, + angle, + axis, + ) + : rotateOnAxis(flycam.currentMatrix, angle, axis); + const updatedRotation = getRotationInDegrees(updatedMatrix); return update(state, { flycam: { currentMatrix: { - $set: rotateOnAxis(flycam.currentMatrix, angle, axis), + $set: updatedMatrix, + }, + rotation: { + $set: keepRotationInBounds(updatedRotation), }, }, }); @@ -101,7 +111,7 @@ function resetMatrix(matrix: Matrix4x4, voxelSize: Vector3) { const scale = getMatrixScale(voxelSize); // Save position const position = [matrix[12], matrix[13], matrix[14]]; - // Reset rotation + // Reset rotation. The default rotation of 180 degree around z is applied. const newMatrix = rotateOnAxis(M4x4.scale(scale, M4x4.identity(), []), Math.PI, [0, 0, 1]); // Restore position newMatrix[12] = position[0]; @@ -166,6 +176,8 @@ export function setDirectionReducer(state: WebknossosState, direction: Vector3) }); } +// The way rotations are currently handled / interpreted is quirky. See here for more information: +// https://www.notion.so/scalableminds/3D-Rotations-3D-Scene-210b51644c6380c2a4a6f5f3c069738a?source=copy_link#22bb51644c63800fb874e717e49da7bc export function setRotationReducer(state: WebknossosState, rotation: Vector3) { if (state.dataset != null) { const [x, y, z] = rotation; @@ -178,6 +190,9 @@ export function setRotationReducer(state: WebknossosState, rotation: Vector3) { currentMatrix: { $set: matrix, }, + rotation: { + $set: keepRotationInBounds(rotation), + }, }, }); } @@ -193,6 +208,9 @@ function FlycamReducer(state: WebknossosState, action: Action): WebknossosState currentMatrix: { $set: resetMatrix(state.flycam.currentMatrix, action.dataset.dataSource.scale.factor), }, + rotation: { + $set: [0, 0, 0], + }, }, }); } @@ -275,13 +293,7 @@ function FlycamReducer(state: WebknossosState, action: Action): WebknossosState } case "SET_ROTATION": { - // This action should only be dispatched when *not* being in orthogonal mode, - // because this would lead to incorrect buckets being selected for rendering. - if (state.temporaryConfiguration.viewMode !== "orthogonal") { - return setRotationReducer(state, action.rotation); - } - // No-op - return state; + return setRotationReducer(state, action.rotation); } case "SET_DIRECTION": { @@ -293,7 +305,6 @@ function FlycamReducer(state: WebknossosState, action: Action): WebknossosState // if the action vector is invalid, do not update return state; } - const newMatrix = M4x4.translate(action.vector, state.flycam.currentMatrix, []); return update(state, { flycam: { @@ -304,42 +315,60 @@ function FlycamReducer(state: WebknossosState, action: Action): WebknossosState }); } + case "MOVE_FLYCAM_ABSOLUTE": { + if (action.vector.includes(Number.NaN)) { + // if the action vector is invalid, do not update + return state; + } + return moveReducer(state, action.vector); + } + case "MOVE_FLYCAM_ORTHO": { const vector = _.clone(action.vector); const { planeId } = action; + const flycamRotation = getRotationInRadian(state.flycam); + flycamRotationMatrix.makeRotationFromEuler(flycamRotationEuler.set(...flycamRotation, "ZYX")); + let deltaInWorldV3 = deltaInWorld + .set(...vector) + .applyMatrix4(flycamRotationMatrix) + .toArray(); + // if planeID is given, use it to manipulate z if (planeId != null && state.userConfiguration.dynamicSpaceDirection) { // change direction of the value connected to space, based on the last direction - const dim = Dimensions.getIndices(planeId)[2]; - vector[dim] *= state.flycam.spaceDirectionOrtho[dim]; + deltaInWorldV3 = V3.multiply(deltaInWorldV3, state.flycam.spaceDirectionOrtho); } - return moveReducer(state, vector); + return moveReducer(state, deltaInWorldV3); } case "MOVE_PLANE_FLYCAM_ORTHO": { - const { dataset } = state; + const { dataset, flycam } = state; if (dataset != null) { const { planeId, increaseSpeedWithZoom } = action; const vector = Dimensions.transDim(action.vector, planeId); - const zoomFactor = increaseSpeedWithZoom ? state.flycam.zoomStep : 1; + const flycamRotation = getRotationInRadian(flycam); + + flycamRotationMatrix.makeRotationFromEuler( + flycamRotationEuler.set(...flycamRotation, "ZYX"), + ); + deltaInWorld.set(...vector).applyMatrix4(flycamRotationMatrix); + const zoomFactor = increaseSpeedWithZoom ? flycam.zoomStep : 1; const scaleFactor = getBaseVoxelFactorsInUnit(dataset.dataSource.scale); - const delta: Vector3 = [ - vector[0] * zoomFactor * scaleFactor[0], - vector[1] * zoomFactor * scaleFactor[1], - vector[2] * zoomFactor * scaleFactor[2], - ]; + let deltaInWorldZoomed = V3.multiply( + V3.scale(deltaInWorld.toArray(), zoomFactor), + scaleFactor, + ); if (planeId != null && state.userConfiguration.dynamicSpaceDirection) { // change direction of the value connected to space, based on the last direction - const dim = Dimensions.getIndices(planeId)[2]; - delta[dim] *= state.flycam.spaceDirectionOrtho[dim]; + deltaInWorldZoomed = V3.multiply(deltaInWorldZoomed, state.flycam.spaceDirectionOrtho); } - return moveReducer(state, delta); + return moveReducer(state, deltaInWorldZoomed); } return state; diff --git a/frontend/javascripts/viewer/model/reducers/settings_reducer.ts b/frontend/javascripts/viewer/model/reducers/settings_reducer.ts index 107e6eefb5d..8619c576106 100644 --- a/frontend/javascripts/viewer/model/reducers/settings_reducer.ts +++ b/frontend/javascripts/viewer/model/reducers/settings_reducer.ts @@ -14,7 +14,6 @@ import { import type { Action } from "viewer/model/actions/actions"; import { updateKey, updateKey2, updateKey3 } from "viewer/model/helpers/deep_update"; import type { ActiveMappingInfo, WebknossosState } from "viewer/store"; -import { setRotationReducer } from "./flycam_reducer"; // // Update helpers @@ -179,16 +178,9 @@ function SettingsReducer(state: WebknossosState, action: Action): WebknossosStat const { allowedModes } = state.annotation.restrictions; if (allowedModes.includes(action.viewMode)) { - const newState = updateTemporaryConfig(state, { + return updateTemporaryConfig(state, { viewMode: action.viewMode, }); - if (action.viewMode !== "orthogonal") { - return newState; - } - // Restore rotation because it might have been changed by the user - // in flight/oblique mode. Since this affects the matrix (which is - // also used in orthogonal mode), the rotation needs to be reset. - return setRotationReducer(newState, [0, 0, 0]); } else { return state; } diff --git a/frontend/javascripts/viewer/model/sagas/prefetch_saga.ts b/frontend/javascripts/viewer/model/sagas/prefetch_saga.ts index f9da4305726..d3394cc0a5a 100644 --- a/frontend/javascripts/viewer/model/sagas/prefetch_saga.ts +++ b/frontend/javascripts/viewer/model/sagas/prefetch_saga.ts @@ -7,6 +7,7 @@ import { getActiveMagIndexForLayer, getAreasFromState, getPosition, + isRotated, } from "viewer/model/accessors/flycam_accessor"; import { FlycamActions } from "viewer/model/actions/flycam_actions"; import { PrefetchStrategyArbitrary } from "viewer/model/bucket_data_handling/prefetch_strategy_arbitrary"; @@ -44,14 +45,18 @@ export function* watchDataRelevantChanges(): Saga { function* shouldPrefetchForDataLayer(dataLayer: DataLayer): Saga { // There is no need to prefetch data for layers that are not visible - return yield* select((state) => - isLayerVisible( - state.dataset, - dataLayer.name, - state.datasetConfiguration, - state.temporaryConfiguration.viewMode, - ), - ); + return yield* select((state) => { + const isNotRotated = !isRotated(state.flycam); + return ( + isNotRotated && + isLayerVisible( + state.dataset, + dataLayer.name, + state.datasetConfiguration, + state.temporaryConfiguration.viewMode, + ) + ); + }); } export function* triggerDataPrefetching(previousProperties: Record): Saga { diff --git a/frontend/javascripts/viewer/model/sagas/saving/save_queue_filling.ts b/frontend/javascripts/viewer/model/sagas/saving/save_queue_filling.ts index f25c68947a3..5660efe9ddc 100644 --- a/frontend/javascripts/viewer/model/sagas/saving/save_queue_filling.ts +++ b/frontend/javascripts/viewer/model/sagas/saving/save_queue_filling.ts @@ -31,7 +31,7 @@ import { } from "viewer/model/sagas/volume/update_actions"; import { diffVolumeTracing } from "viewer/model/sagas/volumetracing_saga"; import type { CameraData, Flycam, SkeletonTracing, VolumeTracing } from "viewer/store"; -import { getFlooredPosition, getRotation } from "../../accessors/flycam_accessor"; +import { getFlooredPosition, getRotationInDegrees } from "../../accessors/flycam_accessor"; import type { Action } from "../../actions/actions"; import type { BatchedAnnotationInitializationAction } from "../../actions/annotation_actions"; @@ -177,7 +177,7 @@ export function performDiffAnnotation( updateCameraAnnotation( getFlooredPosition(flycam), flycam.additionalCoordinates, - getRotation(flycam), + getRotationInDegrees(flycam), flycam.zoomStep, ), ); diff --git a/frontend/javascripts/viewer/model/sagas/skeletontracing_saga.ts b/frontend/javascripts/viewer/model/sagas/skeletontracing_saga.ts index a297aaa87a8..013b71e0cd0 100644 --- a/frontend/javascripts/viewer/model/sagas/skeletontracing_saga.ts +++ b/frontend/javascripts/viewer/model/sagas/skeletontracing_saga.ts @@ -2,12 +2,15 @@ import { getAgglomerateSkeleton, getEditableAgglomerateSkeleton } from "admin/re import { Modal } from "antd"; import DiffableMap, { diffDiffableMaps } from "libs/diffable_map"; import ErrorHandling from "libs/error_handling"; +import { V3 } from "libs/mjs"; import createProgressCallback from "libs/progress_callback"; import type { Message } from "libs/toast"; import Toast from "libs/toast"; +import { map3 } from "libs/utils"; import _ from "lodash"; import memoizeOne from "memoize-one"; import messages from "messages"; +import * as THREE from "three"; import { actionChannel, all, @@ -20,7 +23,12 @@ import { throttle, } from "typed-redux-saga"; import { AnnotationLayerEnum, type ServerSkeletonTracing } from "types/api_types"; -import { TreeTypeEnum } from "viewer/constants"; +import { + NumberToOrthoView, + OrthoBaseRotations, + TreeTypeEnum, + type Vector3, +} from "viewer/constants"; import { getLayerByName } from "viewer/model/accessors/dataset_accessor"; import { enforceSkeletonTracing, @@ -75,10 +83,32 @@ import { api } from "viewer/singletons"; import type { SkeletonTracing, WebknossosState } from "viewer/store"; import Store from "viewer/store"; import { diffBoundingBoxes, diffGroups } from "../helpers/diff_helpers"; -import type { Node, NodeMap, Tree, TreeMap } from "../types/tree_types"; +import { + eulerAngleToReducerInternalMatrix, + reducerInternalMatrixToEulerAngle, +} from "../helpers/rotation_helpers"; +import type { MutableNode, Node, NodeMap, Tree, TreeMap } from "../types/tree_types"; import { ensureWkReady } from "./ready_sagas"; import { takeWithBatchActionSupport } from "./saga_helpers"; +function getNodeRotationWithoutPlaneRotation(activeNode: Readonly): Vector3 { + // In orthogonal view mode, this active planes default rotation is added to the flycam rotation upon node creation. + // To get the same flycam rotation as was active during node creation, the default rotation is calculated out from the nodes rotation. + const nodeRotationRadian = map3(THREE.MathUtils.degToRad, activeNode.rotation); + const nodeRotationInReducerFormatMatrix = eulerAngleToReducerInternalMatrix(nodeRotationRadian); + const viewportRotationMatrix = new THREE.Matrix4().makeRotationFromEuler( + OrthoBaseRotations[NumberToOrthoView[activeNode.viewport]], + ); + // Invert the rotation of the viewport to get the rotation configured during node creation. + const viewportRotationMatrixInverted = viewportRotationMatrix.invert(); + const rotationWithoutViewportRotation = nodeRotationInReducerFormatMatrix.multiply( + viewportRotationMatrixInverted, + ); + const rotationInRadian = reducerInternalMatrixToEulerAngle(rotationWithoutViewportRotation); + const flycamOnlyRotationInDegree = V3.round(map3(THREE.MathUtils.radToDeg, rotationInRadian)); + return flycamOnlyRotationInDegree; +} + function* centerActiveNode(action: Action): Saga { if ("suppressCentering" in action && action.suppressCentering) { return; @@ -97,16 +127,34 @@ function* centerActiveNode(action: Action): Saga { const activeNode = getActiveNode( yield* select((state: WebknossosState) => enforceSkeletonTracing(state.annotation)), ); + const viewMode = yield* select((state: WebknossosState) => state.temporaryConfiguration.viewMode); + const userApplyRotation = yield* select( + (state: WebknossosState) => state.userConfiguration.applyNodeRotationOnActivation, + ); + const applyRotation = + "suppressRotation" in action && action.suppressRotation != null + ? !action.suppressRotation + : userApplyRotation; if (activeNode != null) { + let nodeRotation = activeNode.rotation; + if (applyRotation && viewMode === "orthogonal") { + nodeRotation = yield* call(getNodeRotationWithoutPlaneRotation, activeNode); + } const activeNodePosition = yield* select((state: WebknossosState) => getNodePosition(activeNode, state), ); if ("suppressAnimation" in action && action.suppressAnimation) { Store.dispatch(setPositionAction(activeNodePosition)); - Store.dispatch(setRotationAction(activeNode.rotation)); + if (applyRotation) { + Store.dispatch(setRotationAction(nodeRotation)); + } } else { - api.tracing.centerPositionAnimated(activeNodePosition, false, activeNode.rotation); + api.tracing.centerPositionAnimated( + activeNodePosition, + false, + applyRotation ? nodeRotation : undefined, + ); } if (activeNode.additionalCoordinates) { Store.dispatch(setAdditionalCoordinatesAction(activeNode.additionalCoordinates)); diff --git a/frontend/javascripts/viewer/model/sagas/volumetracing_saga.tsx b/frontend/javascripts/viewer/model/sagas/volumetracing_saga.tsx index 1eb7a78be93..1e812b3a149 100644 --- a/frontend/javascripts/viewer/model/sagas/volumetracing_saga.tsx +++ b/frontend/javascripts/viewer/model/sagas/volumetracing_saga.tsx @@ -614,7 +614,7 @@ function* getGlobalMousePosition(): Saga { return calculateMaybeGlobalPos(state, { x, y, - }); + })?.rounded; } return undefined; diff --git a/frontend/javascripts/viewer/model_initialization.ts b/frontend/javascripts/viewer/model_initialization.ts index 14bb62291c6..bd2132850d2 100644 --- a/frontend/javascripts/viewer/model_initialization.ts +++ b/frontend/javascripts/viewer/model_initialization.ts @@ -676,8 +676,7 @@ function determineDefaultState( } = urlState; // If there is no editPosition (e.g. when viewing a dataset) and // no default position, compute the center of the dataset - const { dataset, datasetConfiguration, temporaryConfiguration } = Store.getState(); - const { viewMode } = temporaryConfiguration; + const { dataset, datasetConfiguration } = Store.getState(); const defaultPosition = datasetConfiguration.position; let position = getDatasetCenter(dataset); let additionalCoordinates = null; @@ -716,19 +715,15 @@ function determineDefaultState( zoomStep = someTracing.zoomLevel; } - let rotation = undefined; - if (viewMode !== "orthogonal") { - rotation = datasetConfiguration.rotation; - - if (userState != null) { - rotation = Utils.point3ToVector3(userState.editRotation); - } else if (someTracing != null) { - rotation = Utils.point3ToVector3(someTracing.editRotation); - } + let rotation = datasetConfiguration.rotation; + if (userState != null) { + rotation = Utils.point3ToVector3(userState.editRotation); + } else if (someTracing != null) { + rotation = Utils.point3ToVector3(someTracing.editRotation); + } - if (urlStateRotation != null) { - rotation = urlStateRotation; - } + if (urlStateRotation != null) { + rotation = urlStateRotation; } const stateByLayer = urlStateByLayer ?? {}; diff --git a/frontend/javascripts/viewer/shaders/coords.glsl.ts b/frontend/javascripts/viewer/shaders/coords.glsl.ts index c3306d19c63..2b6355cb8c4 100644 --- a/frontend/javascripts/viewer/shaders/coords.glsl.ts +++ b/frontend/javascripts/viewer/shaders/coords.glsl.ts @@ -25,11 +25,13 @@ export const getAbsoluteCoords: ShaderModule = { } `, }; -export const getWorldCoordUVW: ShaderModule = { + +export const worldCoordToUVW: ShaderModule = { requirements: [getW, isFlightMode], code: ` - vec3 getWorldCoordUVW() { + vec3 worldCoordToUVW(vec4 worldCoord) { vec3 worldCoordUVW = transDim(worldCoord.xyz); + vec3 positionOffsetUVW = transDim(positionOffset); if (isFlightMode()) { vec4 modelCoords = inverseMatrix(savedModelMatrix) * worldCoord; @@ -44,27 +46,36 @@ export const getWorldCoordUVW: ShaderModule = { worldCoordUVW = (savedModelMatrix * modelCoords).xyz; } - vec3 voxelSizeFactorUVW = transDim(voxelSizeFactor); + vec3 voxelSizeFactorInvertedUVW = transDim(voxelSizeFactorInverted); - worldCoordUVW = vec3( - // For u and w we need to divide by voxelSizeFactor because the threejs scene is scaled - worldCoordUVW.x / voxelSizeFactorUVW.x, - worldCoordUVW.y / voxelSizeFactorUVW.y, + // We subtract the potential offset of the plane and then + // need to multiply by voxelSizeFactorInvertedUVW because the threejs scene is scaled. + worldCoordUVW = (worldCoordUVW - positionOffsetUVW) * voxelSizeFactorInvertedUVW; - // In orthogonal mode, the planes are offset in 3D space to allow skeletons to be rendered before - // each plane. Since w (e.g., z for xy plane) is - // the same for all texels computed in this shader, we simply use globalPosition[w] instead - <% if (isOrthogonal) { %> - getW(globalPosition) - <% } else { %> - worldCoordUVW.z / voxelSizeFactorUVW.z - <% } %> - ); return worldCoordUVW; } `, }; + +export const getWorldCoordUVW: ShaderModule = { + requirements: [worldCoordToUVW], + code: ` + vec3 getWorldCoordUVW() { + return worldCoordToUVW(worldCoord); + } + `, +}; + +export const getUnrotatedWorldCoordUVW: ShaderModule = { + requirements: [worldCoordToUVW], + code: ` + vec3 getUnrotatedWorldCoordUVW() { + return worldCoordToUVW(inverseFlycamRotationMatrix * worldCoord); + } + `, +}; + export const isOutsideOfBoundingBox: ShaderModule = { code: ` bool isOutsideOfBoundingBox(vec3 worldCoordUVW) { diff --git a/frontend/javascripts/viewer/shaders/filtering.glsl.ts b/frontend/javascripts/viewer/shaders/filtering.glsl.ts index 47299b96a14..c7ed21fedfa 100644 --- a/frontend/javascripts/viewer/shaders/filtering.glsl.ts +++ b/frontend/javascripts/viewer/shaders/filtering.glsl.ts @@ -90,7 +90,11 @@ const getMaybeFilteredColor: ShaderModule = { vec4 color; if (!suppressBilinearFiltering && useBilinearFiltering) { <% if (isOrthogonal) { %> - color = getBilinearColorFor(layerIndex, d_texture_width, packingDegree, worldPositionUVW); + if(isFlycamRotated){ + color = getTrilinearColorFor(layerIndex, d_texture_width, packingDegree, worldPositionUVW); + } else { + color = getBilinearColorFor(layerIndex, d_texture_width, packingDegree, worldPositionUVW); + } <% } else { %> color = getTrilinearColorFor(layerIndex, d_texture_width, packingDegree, worldPositionUVW); <% } %> diff --git a/frontend/javascripts/viewer/shaders/main_data_shaders.glsl.ts b/frontend/javascripts/viewer/shaders/main_data_shaders.glsl.ts index 6c205a4e9ec..25865deb376 100644 --- a/frontend/javascripts/viewer/shaders/main_data_shaders.glsl.ts +++ b/frontend/javascripts/viewer/shaders/main_data_shaders.glsl.ts @@ -58,6 +58,7 @@ export type Params = { >; magnificationsCount: number; voxelSizeFactor: Vector3; + voxelSizeFactorInverted: Vector3; isOrthogonal: boolean; tpsTransformPerLayer: Record; }; @@ -69,6 +70,8 @@ uniform float activeMagIndices[<%= globalLayerCount %>]; uniform uint availableLayerIndexToGlobalLayerIndex[<%= globalLayerCount %>]; uniform vec3 allMagnifications[<%= magnificationsCount %>]; uniform uint magnificationCountCumSum[<%= globalLayerCount %>]; +uniform bool isFlycamRotated; +uniform mat4 inverseFlycamRotationMatrix; uniform highp usampler2D lookup_texture; uniform highp uint lookup_seeds[3]; @@ -131,7 +134,7 @@ uniform float alpha; uniform bool renderBucketIndices; uniform vec3 bboxMin; uniform vec3 bboxMax; -uniform vec3 globalPosition; +uniform vec3 positionOffset; uniform vec3 activeSegmentPosition; uniform float zoomValue; uniform bool useBilinearFiltering; @@ -150,6 +153,7 @@ uniform uint hoveredUnmappedSegmentIdHigh; // rendering of the brush circle (and issues in the arbitrary modes). That's why it // is directly inserted into the source via templating. const vec3 voxelSizeFactor = <%= formatVector3AsVec3(voxelSizeFactor) %>; +const vec3 voxelSizeFactorInverted = <%= formatVector3AsVec3(voxelSizeFactorInverted) %>; const vec4 fallbackGray = vec4(0.5, 0.5, 0.5, 1.0); const float bucketWidth = <%= bucketWidth %>; @@ -478,21 +482,26 @@ void main() { worldCoord = modelMatrix * vec4(position, 1.0); gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); + // Early return shader as optimized vertex positioning at bucket borders currently does not work while rotations are active. + // This shouldn't really impact the performance as isFlycamRotated is a uniform. + if(isFlycamRotated || !<%= isOrthogonal %>) { + return; + } // Remember the original z position, since it can subtly diverge in the // following calculations due to floating point inaccuracies. This can // result in artifacts, such as the crosshair disappearing. float originalZ = gl_Position.z; // Remember, the top of the viewport has Y=1 whereas the left has X=-1. - vec3 worldCoordTopLeft = transDim((modelMatrix * vec4(-PLANE_WIDTH/2., PLANE_WIDTH/2., 0., 1.)).xyz); - vec3 worldCoordBottomRight = transDim((modelMatrix * vec4( PLANE_WIDTH/2., -PLANE_WIDTH/2., 0., 1.)).xyz); + vec3 worldCoordTopLeft = transDim((modelMatrix * vec4(-PLANE_WIDTH/2., -PLANE_WIDTH/2., 0., 1.)).xyz); + vec3 worldCoordBottomRight = transDim((modelMatrix * vec4( PLANE_WIDTH/2., PLANE_WIDTH/2., 0., 1.)).xyz); // The following code ensures that the vertices are aligned with the bucket borders // of the currently rendered magnification. // In general, an index i is computed for each vertex so that each vertex can be moved // to the right/bottom border of the i-th bucket. - // Exceptions are the first and the last vertex which aren't moved so that the plane - // keeps its original extent. + // To ensure that the outer vertices are not moved to the next lower / higher bucket border + // the vertices are clamped to stay in range of worldCoordTopLeft and worldCoordBottomRight. // Calculate the index of the vertex (e.g., index.x=0 is the first horizontal vertex). // Let's only consider x: @@ -512,30 +521,27 @@ void main() { vec2 d = transDim(vec3(bucketWidth) * representativeMagForVertexAlignment).xy; vec3 voxelSizeFactorUVW = transDim(voxelSizeFactor); + vec3 voxelSizeFactorInvertedUVW = transDim(voxelSizeFactorInverted); vec3 transWorldCoord = transDim(worldCoord.xyz); - if (index.x >= 1. && index.x <= PLANE_SUBDIVISION - 1.) { - transWorldCoord.x = - ( - // Left border of left-most bucket (probably outside of visible plane) - floor(worldCoordTopLeft.x / voxelSizeFactorUVW.x / d.x) * d.x - // Move by index.x buckets to the right. - + index.x * d.x - ) * voxelSizeFactorUVW.x; - - transWorldCoord.x = clamp(transWorldCoord.x, worldCoordTopLeft.x, worldCoordBottomRight.x); - } - - if (index.y >= 1. && index.y <= PLANE_SUBDIVISION - 1.) { - transWorldCoord.y = - ( - // Top border of top-most bucket (probably outside of visible plane) - floor(worldCoordTopLeft.y / voxelSizeFactorUVW.y / d.y) * d.y - // Move by index.y buckets to the bottom. - + index.y * d.y - ) * voxelSizeFactorUVW.y; - transWorldCoord.y = clamp(transWorldCoord.y, worldCoordTopLeft.y, worldCoordBottomRight.y); - } + transWorldCoord.x = + ( + // Left border of left-most bucket (probably outside of visible plane) + floor(worldCoordTopLeft.x * voxelSizeFactorInvertedUVW.x / d.x) * d.x + // Move by index.x buckets to the right. + + index.x * d.x + ) * voxelSizeFactorUVW.x; + + transWorldCoord.x = clamp(transWorldCoord.x, worldCoordTopLeft.x, worldCoordBottomRight.x); + + transWorldCoord.y = + ( + // Top border of top-most bucket (probably outside of visible plane) + floor(worldCoordTopLeft.y * voxelSizeFactorInvertedUVW.y / d.y) * d.y + // Move by index.y buckets to the bottom. + + index.y * d.y + ) * voxelSizeFactorUVW.y; + transWorldCoord.y = clamp(transWorldCoord.y, worldCoordTopLeft.y, worldCoordBottomRight.y); worldCoord = vec4(transDim(transWorldCoord), 1.); diff --git a/frontend/javascripts/viewer/shaders/segmentation.glsl.ts b/frontend/javascripts/viewer/shaders/segmentation.glsl.ts index fcf5e85791e..79d2db978b0 100644 --- a/frontend/javascripts/viewer/shaders/segmentation.glsl.ts +++ b/frontend/javascripts/viewer/shaders/segmentation.glsl.ts @@ -10,6 +10,7 @@ import { jsGetElementOfPermutation, jsRgb2hsv, } from "viewer/shaders/utils.glsl"; +import { getUnrotatedWorldCoordUVW } from "./coords.glsl"; import { hashCombine } from "./hashing.glsl"; import { attemptMappingLookUp } from "./mappings.glsl"; import type { ShaderModule } from "./shader_module_system"; @@ -29,7 +30,14 @@ const permutations = { }; export const convertCellIdToRGB: ShaderModule = { - requirements: [hsvToRgb, getElementOfPermutation, aaStep, colormapJet, hashCombine], + requirements: [ + hsvToRgb, + getElementOfPermutation, + aaStep, + colormapJet, + hashCombine, + getUnrotatedWorldCoordUVW, + ], code: ` highp uint vec4ToUint(vec4 idLow) { uint integerValue = (uint(idLow.a) << 24) | (uint(idLow.b) << 16) | (uint(idLow.g) << 8) | uint(idLow.r); @@ -214,7 +222,7 @@ export const convertCellIdToRGB: ShaderModule = { // Round the zoomValue so that the pattern frequency only changes at distinct steps. Otherwise, zooming out // wouldn't change the pattern at all, which would feel weird. float zoomAdaption = ceil(zoomValue); - vec3 worldCoordUVW = coordScaling * getWorldCoordUVW() / zoomAdaption; + vec3 worldCoordUVW = coordScaling * getUnrotatedWorldCoordUVW() / zoomAdaption; float baseVoxelSize = min(min(voxelSizeFactor.x, voxelSizeFactor.y), voxelSizeFactor.z); vec3 anisotropyFactorUVW = transDim(voxelSizeFactor) / baseVoxelSize; @@ -347,7 +355,7 @@ export const getCrossHairOverlay: ShaderModule = { return vec4(0.0); } - vec3 flooredGlobalPosUVW = transDim(floor(globalPosition)); + vec3 flooredGlobalPosUVW = transDim(floor(worldCoordUVW)); vec3 activeSegmentPosUVW = transDim(activeSegmentPosition); // Compute the anisotropy of the dataset so that the cross hair looks the same in diff --git a/frontend/javascripts/viewer/shaders/texture_access.glsl.ts b/frontend/javascripts/viewer/shaders/texture_access.glsl.ts index ca9663caac4..94f4b4645e1 100644 --- a/frontend/javascripts/viewer/shaders/texture_access.glsl.ts +++ b/frontend/javascripts/viewer/shaders/texture_access.glsl.ts @@ -206,7 +206,7 @@ export const getColorForCoords: ShaderModule = { // To avoid rare rendering artifacts, don't use the precomputed // bucket address when being at the border of buckets. - bool beSafe = false; + bool beSafe = isFlycamRotated || !<%= isOrthogonal %>; { renderedMagIdx = outputMagIdx[globalLayerIndex]; vec3 coords = floor(getAbsoluteCoords(worldPositionUVW, renderedMagIdx, globalLayerIndex)); diff --git a/frontend/javascripts/viewer/shaders/thin_plate_spline.glsl.ts b/frontend/javascripts/viewer/shaders/thin_plate_spline.glsl.ts index 31db3a1aff1..c692aab874d 100644 --- a/frontend/javascripts/viewer/shaders/thin_plate_spline.glsl.ts +++ b/frontend/javascripts/viewer/shaders/thin_plate_spline.glsl.ts @@ -74,7 +74,7 @@ export function generateCalculateTpsOffsetFunction(name: string) { bending_part += dist * TPS_W_${name}[cpIdx]; } - vec3 offset = (linear_part + bending_part) / voxelSizeFactor; + vec3 offset = (linear_part + bending_part) * voxelSizeFactorInverted; return offset; } `; diff --git a/frontend/javascripts/viewer/store.ts b/frontend/javascripts/viewer/store.ts index a16a7906473..0c220532519 100644 --- a/frontend/javascripts/viewer/store.ts +++ b/frontend/javascripts/viewer/store.ts @@ -324,6 +324,7 @@ export type UserConfiguration = { readonly newNodeNewTree: boolean; readonly continuousNodeCreation: boolean; readonly centerNewNode: boolean; + readonly applyNodeRotationOnActivation: boolean; readonly overrideNodeRadius: boolean; readonly particleSize: number; readonly presetBrushSizes: BrushPresets | null; @@ -423,6 +424,7 @@ export type Flycam = { readonly additionalCoordinates: AdditionalCoordinate[] | null; readonly spaceDirectionOrtho: [-1 | 1, -1 | 1, -1 | 1]; readonly direction: Vector3; + readonly rotation: Vector3; }; export type CameraData = { readonly near: number; diff --git a/frontend/javascripts/viewer/view/action-bar/dataset_position_view.tsx b/frontend/javascripts/viewer/view/action-bar/dataset_position_view.tsx index 0f47808428a..d74dedd4f72 100644 --- a/frontend/javascripts/viewer/view/action-bar/dataset_position_view.tsx +++ b/frontend/javascripts/viewer/view/action-bar/dataset_position_view.tsx @@ -1,21 +1,123 @@ +import { PushpinOutlined } from "@ant-design/icons"; +import { Space } from "antd"; +import FastTooltip from "components/fast_tooltip"; +import { V3 } from "libs/mjs"; import { useWkSelector } from "libs/react_hooks"; -import constants from "viewer/constants"; -import PositionView from "./position_view"; -import RotationView from "./rotation_view"; +import Toast from "libs/toast"; +import { Vector3Input } from "libs/vector_input"; +import message from "messages"; +import type React from "react"; +import type { Vector3 } from "viewer/constants"; +import { getDatasetExtentInVoxel } from "viewer/model/accessors/dataset_accessor"; +import { getPosition } from "viewer/model/accessors/flycam_accessor"; +import { setPositionAction } from "viewer/model/actions/flycam_actions"; +import Store from "viewer/store"; +import { ShareButton } from "viewer/view/action-bar/share_modal_view"; +import ButtonComponent from "viewer/view/components/button_component"; +import DatasetRotationPopoverButtonView from "./dataset_rotation_popover_view"; + +const positionIconStyle: React.CSSProperties = { + transform: "rotate(-45deg)", + marginRight: 0, +}; +const warningColors: React.CSSProperties = { + color: "rgb(255, 155, 85)", + borderColor: "rgb(241, 122, 39)", +}; +const iconErrorStyle: React.CSSProperties = { ...warningColors }; +const positionInputDefaultStyle: React.CSSProperties = { + textAlign: "center", +}; +const positionInputErrorStyle: React.CSSProperties = { + ...positionInputDefaultStyle, + ...warningColors, +}; function DatasetPositionView() { - const viewMode = useWkSelector((state) => state.temporaryConfiguration.viewMode); - const isArbitraryMode = constants.MODES_ARBITRARY.includes(viewMode); + const flycam = useWkSelector((state) => state.flycam); + const dataset = useWkSelector((state) => state.dataset); + const task = useWkSelector((state) => state.task); + + const copyPositionToClipboard = async () => { + const position = V3.floor(getPosition(flycam)).join(", "); + await navigator.clipboard.writeText(position); + Toast.success("Position copied to clipboard"); + }; + + const handleChangePosition = (position: Vector3) => { + Store.dispatch(setPositionAction(position)); + }; + + const isPositionOutOfBounds = (position: Vector3) => { + const { min: datasetMin, max: datasetMax } = getDatasetExtentInVoxel(dataset); + + const isPositionOutOfBounds = (min: Vector3, max: Vector3) => + position[0] < min[0] || + position[1] < min[1] || + position[2] < min[2] || + position[0] >= max[0] || + position[1] >= max[1] || + position[2] >= max[2]; + + const isOutOfDatasetBounds = isPositionOutOfBounds(datasetMin, datasetMax); + let isOutOfTaskBounds = false; + + if (task?.boundingBox) { + const bbox = task.boundingBox; + const bboxMax: Vector3 = [ + bbox.topLeft[0] + bbox.width, + bbox.topLeft[1] + bbox.height, + bbox.topLeft[2] + bbox.depth, + ]; + isOutOfTaskBounds = isPositionOutOfBounds(bbox.topLeft, bboxMax); + } + + return { + isOutOfDatasetBounds, + isOutOfTaskBounds, + }; + }; + + const position = V3.floor(getPosition(flycam)); + const { isOutOfDatasetBounds, isOutOfTaskBounds } = isPositionOutOfBounds(position); + const iconColoringStyle = isOutOfDatasetBounds || isOutOfTaskBounds ? iconErrorStyle : {}; + const positionInputStyle = + isOutOfDatasetBounds || isOutOfTaskBounds ? positionInputErrorStyle : positionInputDefaultStyle; + let maybeErrorMessage = null; + + if (isOutOfDatasetBounds) { + maybeErrorMessage = message["tracing.out_of_dataset_bounds"]; + } else if (!maybeErrorMessage && isOutOfTaskBounds) { + maybeErrorMessage = message["tracing.out_of_task_bounds"]; + } return ( -
- - {isArbitraryMode ? : null} -
+ + + + + + + + + + + + ); } diff --git a/frontend/javascripts/viewer/view/action-bar/dataset_rotation_popover_view.tsx b/frontend/javascripts/viewer/view/action-bar/dataset_rotation_popover_view.tsx new file mode 100644 index 00000000000..868b9baa2b9 --- /dev/null +++ b/frontend/javascripts/viewer/view/action-bar/dataset_rotation_popover_view.tsx @@ -0,0 +1,108 @@ +import { RollbackOutlined, SyncOutlined } from "@ant-design/icons"; +import { Button, Col, Popover, Row } from "antd"; +import { useWkSelector } from "libs/react_hooks"; +import type React from "react"; +import { useCallback } from "react"; +import type { EmptyObject } from "types/globals"; +import type { Vector3 } from "viewer/constants"; +import { isRotated } from "viewer/model/accessors/flycam_accessor"; +import { setRotationAction } from "viewer/model/actions/flycam_actions"; +import Store from "viewer/store"; +import { NumberSliderSetting } from "../components/setting_input_views"; + +export const warningColors: React.CSSProperties = { + color: "rgb(255, 155, 85)", + borderColor: "rgb(241, 122, 39)", +}; + +const PopoverContent: React.FC = () => { + const rotation = useWkSelector((state) => state.flycam.rotation); + const handleChangeRotation = useCallback((rotation: Vector3) => { + Store.dispatch(setRotationAction(rotation)); + }, []); + + return ( +
+
+ handleChangeRotation([newValue, rotation[1], rotation[2]])} + spans={[3, 13, 4, 4]} + postComponent={ +
+
+ handleChangeRotation([rotation[0], newValue, rotation[2]])} + spans={[3, 13, 4, 4]} + postComponent={ +
+
+ handleChangeRotation([rotation[0], rotation[1], newValue])} + spans={[3, 13, 4, 4]} + postComponent={ +
+
+ + + + + +
+
+ ); +}; + +const DatasetRotationPopoverButtonView: React.FC<{ style: React.CSSProperties }> = ({ style }) => { + const isFlycamRotated = useWkSelector((state) => isRotated(state.flycam)); + const maybeWarningStyle = isFlycamRotated ? { ...style, ...warningColors, zIndex: 1 } : style; + return ( + }> + + - ) : ( - - - - This may be slow for very large tracings. - - - )} - - ); - } -} - -function mapStateToProps(state: WebknossosState): Partial { - return { - skeletonTracing: state.annotation.skeleton, - }; -} + > + This may be slow for very large tracings. + + + )} + + ); +}; -const connector = connect(mapStateToProps); -export default connector(AbstractTreeTab); +export default AbstractTreeTab; diff --git a/frontend/javascripts/viewer/view/right-border-tabs/comment_tab/comment.tsx b/frontend/javascripts/viewer/view/right-border-tabs/comment_tab/comment.tsx index 0abd1ed0ef5..66c81a0cb0d 100644 --- a/frontend/javascripts/viewer/view/right-border-tabs/comment_tab/comment.tsx +++ b/frontend/javascripts/viewer/view/right-border-tabs/comment_tab/comment.tsx @@ -3,6 +3,7 @@ import type * as React from "react"; import classNames from "classnames"; import { document } from "libs/window"; +import { useCallback } from "react"; import { NODE_ID_REF_REGEX, POSITION_REF_REGEX } from "viewer/constants"; import { setActiveNodeAction } from "viewer/model/actions/skeletontracing_actions"; import type { CommentType } from "viewer/model/types/tree_types"; @@ -54,9 +55,9 @@ function ActiveCommentPopover({ } export function Comment({ comment, isActive }: CommentProps) { - const handleClick = () => { + const handleClick = useCallback(() => { Store.dispatch(setActiveNodeAction(comment.nodeId)); - }; + }, [comment.nodeId]); const liClassName = classNames("markdown", "markdown-small", "nowrap"); diff --git a/frontend/javascripts/viewer/view/statusbar.tsx b/frontend/javascripts/viewer/view/statusbar.tsx index 1783bf92a7c..59db11145e3 100644 --- a/frontend/javascripts/viewer/view/statusbar.tsx +++ b/frontend/javascripts/viewer/view/statusbar.tsx @@ -415,6 +415,7 @@ function maybeLabelWithSegmentationWarning(isUint64SegmentationVisible: boolean, function Infos() { const isSkeletonAnnotation = useWkSelector((state) => state.annotation.skeleton != null); const activeVolumeTracing = useWkSelector((state) => getActiveSegmentationTracing(state)); + const activeCellId = activeVolumeTracing?.activeCellId; const activeNodeId = useWkSelector((state) => state.annotation.skeleton ? state.annotation.skeleton.activeNodeId : null, @@ -429,7 +430,9 @@ function Infos() { [dispatch], ); const onChangeActiveNodeId = useCallback( - (id: number) => dispatch(setActiveNodeAction(id)), + (id: number) => { + dispatch(setActiveNodeAction(id)); + }, [dispatch], ); const onChangeActiveTreeId = useCallback( @@ -569,7 +572,7 @@ function SegmentAndMousePosition() { const mousePosition = useWkSelector((state) => state.temporaryConfiguration.mousePosition); const additionalCoordinates = useWkSelector((state) => state.flycam.additionalCoordinates); const isPlaneMode = useWkSelector((state) => getIsPlaneMode(state)); - const globalMousePosition = useWkSelector((state) => { + const globalMousePositionRounded = useWkSelector((state) => { const { activeViewport } = state.viewModeData.plane; if (mousePosition && activeViewport !== OrthoViews.TDView) { @@ -577,7 +580,7 @@ function SegmentAndMousePosition() { return calculateGlobalPos(state, { x, y, - }); + }).rounded; } return undefined; @@ -589,7 +592,9 @@ function SegmentAndMousePosition() { {isPlaneMode ? ( Pos [ - {globalMousePosition ? getPosString(globalMousePosition, additionalCoordinates) : "-,-,-"} + {globalMousePositionRounded + ? getPosString(globalMousePositionRounded, additionalCoordinates) + : "-,-,-"} ] ) : null} diff --git a/unreleased_changes/8614.md b/unreleased_changes/8614.md new file mode 100644 index 00000000000..b8d0b78b156 --- /dev/null +++ b/unreleased_changes/8614.md @@ -0,0 +1,2 @@ +### Added +- Added the possibility to rotate the planes in ortho view. While rotated, volume annotation is disabled. \ No newline at end of file