diff --git a/applications/visualizer/backend/visualizer/settings/common.py b/applications/visualizer/backend/visualizer/settings/common.py index d29bc555..b9c2ad3b 100644 --- a/applications/visualizer/backend/visualizer/settings/common.py +++ b/applications/visualizer/backend/visualizer/settings/common.py @@ -153,8 +153,8 @@ # DATASET_EMDATA_SEGMENTATION_URL_FORMAT = "resources/{dataset}/em-data/segmentation/{{index}}" -NEURON_REPRESENTATION_3D_URL_FORMAT = "resources/{{dataset}}/3d-model/{name}" -DATASET_NEURON_REPRESENTATION_3D_URL_FORMAT = "resources/{dataset}/3d-model/{{name}}" +NEURON_REPRESENTATION_3D_URL_FORMAT = "resources/{{dataset}}/3d/{name}.stl" +DATASET_NEURON_REPRESENTATION_3D_URL_FORMAT = "resources/{dataset}/3d/{{name}}.stl" # DATASET_EMDATA_URL_FORMAT = ( # f"resources/sem-adult/catmaid-tiles/{{index}}/{{x}}_{{y}}_{{z}}.jpg" # ) diff --git a/applications/visualizer/frontend/src/components/CustomAutocomplete.tsx b/applications/visualizer/frontend/src/components/CustomAutocomplete.tsx index 2b52570c..37b5dccb 100644 --- a/applications/visualizer/frontend/src/components/CustomAutocomplete.tsx +++ b/applications/visualizer/frontend/src/components/CustomAutocomplete.tsx @@ -20,8 +20,8 @@ interface CustomAutocompleteProps { ChipProps?: ChipProps; sx?: SxProps; componentsProps?: AutocompleteProps["componentsProps"]; - value?: T[]; - onChange: (v: T | T[]) => void; + value?: T; + onChange: (v: T) => void; disabled?: boolean; onInputChange?: (v: string) => void; } @@ -56,7 +56,7 @@ const CommonAutocomplete = ({ disabled={disabled} onChange={(event: React.SyntheticEvent, value) => { event.preventDefault(); - onChange(value); + onChange(value as T); }} clearIcon={clearIcon} options={options} diff --git a/applications/visualizer/frontend/src/components/ViewerContainer/CustomEntitiesDropdown.tsx b/applications/visualizer/frontend/src/components/ViewerContainer/CustomEntitiesDropdown.tsx index d9a38e4d..acec8bad 100644 --- a/applications/visualizer/frontend/src/components/ViewerContainer/CustomEntitiesDropdown.tsx +++ b/applications/visualizer/frontend/src/components/ViewerContainer/CustomEntitiesDropdown.tsx @@ -4,8 +4,8 @@ import { Box, InputAdornment, Popper, TextField, Typography } from "@mui/materia import type React from "react"; import { useEffect, useRef, useState } from "react"; import { CheckIcon } from "../../icons"; -import { vars } from "../../theme/variables.ts"; import type { Neuron } from "../../rest/index.ts"; +import { vars } from "../../theme/variables.ts"; const { gray50, brand600 } = vars; diff --git a/applications/visualizer/frontend/src/components/viewers/ThreeD/DatasetPicker.tsx b/applications/visualizer/frontend/src/components/viewers/ThreeD/DatasetPicker.tsx new file mode 100644 index 00000000..c7c83ac1 --- /dev/null +++ b/applications/visualizer/frontend/src/components/viewers/ThreeD/DatasetPicker.tsx @@ -0,0 +1,81 @@ +import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; +import { IconButton, Typography } from "@mui/material"; +import type React from "react"; +import { CheckIcon, CloseIcon } from "../../../icons"; +import type { Dataset } from "../../../rest"; +import { vars } from "../../../theme/variables.ts"; +import CustomAutocomplete from "../../CustomAutocomplete.tsx"; + +const { gray100, gray600 } = vars; + +interface DatasetPickerProps { + datasets: Dataset[]; + selectedDataset: Dataset; + onDatasetChange: (dataset: Dataset) => void; +} + +const DatasetPicker: React.FC = ({ datasets, selectedDataset, onDatasetChange }) => { + return ( + onDatasetChange(newValue)} + getOptionLabel={(option: Dataset) => option.name} + renderOption={(props, option) => ( +
  • + + {option.name} +
  • + )} + placeholder="Start typing to search" + className="secondary" + id="tags-standard" + popupIcon={} + ChipProps={{ + deleteIcon: ( + + + + ), + }} + sx={{ + position: "absolute", + top: ".5rem", + right: ".5rem", + zIndex: 1, + minWidth: "17.5rem", + "& .MuiInputBase-root": { + padding: "0.5rem 2rem 0.5rem 0.75rem !important", + backgroundColor: gray100, + boxShadow: "0px 1px 2px 0px rgba(16, 24, 40, 0.05)", + "&.Mui-focused": { + "& .MuiOutlinedInput-notchedOutline": { + borderColor: gray100, + boxShadow: "none", + }, + }, + "& .MuiInputBase-input": { + color: gray600, + fontWeight: 500, + }, + }, + }} + componentsProps={{ + paper: { + sx: { + "& .MuiAutocomplete-listbox": { + "& .MuiAutocomplete-option": { + '&[aria-selected="true"]': { + backgroundColor: "transparent !important", + }, + }, + }, + }, + }, + }} + /> + ); +}; + +export default DatasetPicker; diff --git a/applications/visualizer/frontend/src/components/viewers/ThreeD/SceneControls.tsx b/applications/visualizer/frontend/src/components/viewers/ThreeD/SceneControls.tsx index 765f0bb8..d1866c96 100644 --- a/applications/visualizer/frontend/src/components/viewers/ThreeD/SceneControls.tsx +++ b/applications/visualizer/frontend/src/components/viewers/ThreeD/SceneControls.tsx @@ -3,17 +3,18 @@ import ZoomInIcon from "@mui/icons-material/ZoomIn"; import ZoomOutIcon from "@mui/icons-material/ZoomOut"; import { Box, Divider, IconButton, Popover, Typography } from "@mui/material"; import Tooltip from "@mui/material/Tooltip"; -import { useState } from "react"; +import { useRef, useState } from "react"; import { vars } from "../../../theme/variables.ts"; import CustomFormControlLabel from "./CustomFormControlLabel.tsx"; - const { gray500 } = vars; function SceneControls({ cameraControlRef, isWireframe, setIsWireframe }) { const [anchorEl, setAnchorEl] = useState(null); - + const rotateAnimationRef = useRef(null); + const [isRotating, setIsRotating] = useState(false); const open = Boolean(anchorEl); const id = open ? "settings-popover" : undefined; + const handleOpenSettings = (event) => { setAnchorEl(event.currentTarget); }; @@ -22,6 +23,26 @@ function SceneControls({ cameraControlRef, isWireframe, setIsWireframe }) { setAnchorEl(null); }; + const handleRotation = () => { + if (!cameraControlRef.current) return; + + const rotate = () => { + cameraControlRef.current.rotate(0.01, 0, true); + rotateAnimationRef.current = requestAnimationFrame(rotate); + }; + + if (isRotating) { + if (rotateAnimationRef.current) { + cancelAnimationFrame(rotateAnimationRef.current); + rotateAnimationRef.current = null; + } + } else { + rotate(); + } + + setIsRotating(!isRotating); + }; + return ( - @@ -119,7 +139,7 @@ function SceneControls({ cameraControlRef, isWireframe, setIsWireframe }) { - + diff --git a/applications/visualizer/frontend/src/components/viewers/ThreeD/ThreeDViewer.tsx b/applications/visualizer/frontend/src/components/viewers/ThreeD/ThreeDViewer.tsx index 2ef4dd95..5aa9e3a4 100644 --- a/applications/visualizer/frontend/src/components/viewers/ThreeD/ThreeDViewer.tsx +++ b/applications/visualizer/frontend/src/components/viewers/ThreeD/ThreeDViewer.tsx @@ -1,4 +1,9 @@ -import { Suspense, useEffect, useRef, useState } from "react"; +import { CameraControls, PerspectiveCamera } from "@react-three/drei"; +import { Canvas } from "@react-three/fiber"; +import { Suspense, useEffect, useMemo, useRef, useState } from "react"; +import { useSelectedWorkspace } from "../../../hooks/useSelectedWorkspace.ts"; +import { ViewerType, getNeuronUrlForDataset } from "../../../models/models.ts"; +import { type Dataset, OpenAPI } from "../../../rest"; import { CAMERA_FAR, CAMERA_FOV, @@ -9,24 +14,12 @@ import { LIGHT_2_POSITION, SCENE_BACKGROUND, } from "../../../settings/threeDSettings.ts"; - -import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; -import { IconButton, Typography } from "@mui/material"; -import { CameraControls, PerspectiveCamera } from "@react-three/drei"; -import { Canvas } from "@react-three/fiber"; -import { useSelector } from "react-redux"; -import { useGlobalContext } from "../../../contexts/GlobalContext.tsx"; -import { CheckIcon, CloseIcon } from "../../../icons"; -import type { RootState } from "../../../layout-manager/layoutManagerFactory.ts"; -import type { Dataset } from "../../../rest"; -import { vars } from "../../../theme/variables.ts"; -import CustomAutocomplete from "../../CustomAutocomplete.tsx"; +import DatasetPicker from "./DatasetPicker.tsx"; import Gizmo from "./Gizmo.tsx"; import Loader from "./Loader.tsx"; import STLViewer from "./STLViewer.tsx"; import SceneControls from "./SceneControls.tsx"; -const { gray100, gray600 } = vars; export interface Instance { id: string; url: string; @@ -35,100 +28,45 @@ export interface Instance { } function ThreeDViewer() { + const workspace = useSelectedWorkspace(); + const dataSets = useMemo(() => Object.values(workspace.activeDatasets), [workspace.activeDatasets]); + + const [selectedDataset, setSelectedDataset] = useState(dataSets[0]); + const [instances, setInstances] = useState([]); + const [isWireframe, setIsWireframe] = useState(false); + + const cameraControlRef = useRef(null); + // @ts-expect-error 'setShowNeurons' is declared but its value is never read. // eslint-disable-next-line @typescript-eslint/no-unused-vars const [showNeurons, setShowNeurons] = useState(true); // @ts-expect-error 'setShowSynapses' is declared but its value is never read. // eslint-disable-next-line @typescript-eslint/no-unused-vars const [showSynapses, setShowSynapses] = useState(true); - const [instances, setInstances] = useState([]); - const [isWireframe, setIsWireframe] = useState(false); - const currentWorkspaceId = useSelector((state: RootState) => state.workspaceId); - const { workspaces } = useGlobalContext(); - const currentWorkspace = workspaces[currentWorkspaceId]; - const cameraControlRef = useRef(null); useEffect(() => { - if (showNeurons) { - setInstances([ - { - id: "nerve_ring", - url: "resources/nervering-SEM_adult.stl", - color: "white", - opacity: 0.3, - }, - { - id: "adal_sem", - url: "resources/ADAL-SEM_adult.stl", - color: "blue", - opacity: 1, - }, - ]); - } - }, [showNeurons, showSynapses]); + if (!selectedDataset) return; + + const visibleNeurons = workspace.getVisibleNeuronsInThreeD(); + const newInstances: Instance[] = visibleNeurons.flatMap((neuronId) => { + const neuron = workspace.availableNeurons[neuronId]; + const viewerData = workspace.visibilities[neuronId]?.[ViewerType.ThreeD]; + const urls = getNeuronUrlForDataset(neuron, selectedDataset.id); + + return urls.map((url, index) => ({ + id: `${neuronId}-${index}`, + url: `${OpenAPI.BASE}/${url}`, + color: viewerData?.color || "#FFFFFF", + opacity: 1, + })); + }); - const dataSets = Object.values(currentWorkspace.activeDatasets); + setInstances(newInstances); + }, [selectedDataset, workspace.availableNeurons, workspace.visibilities]); return ( <> - console.log(e)} - getOptionLabel={(option: Dataset) => option.name} - renderOption={(props, option) => ( -
  • - - {option.name} -
  • - )} - placeholder="Start typing to search" - className="secondary" - id="tags-standard" - popupIcon={} - ChipProps={{ - deleteIcon: ( - - - - ), - }} - sx={{ - position: "absolute", - top: ".5rem", - right: ".5rem", - zIndex: 1, - minWidth: "17.5rem", - "& .MuiInputBase-root": { - padding: "0.5rem 2rem 0.5rem 0.75rem !important", - backgroundColor: gray100, - boxShadow: "0px 1px 2px 0px rgba(16, 24, 40, 0.05)", - "&.Mui-focused": { - "& .MuiOutlinedInput-notchedOutline": { - borderColor: gray100, - boxShadow: "none", - }, - }, - "& .MuiInputBase-input": { - color: gray600, - fontWeight: 500, - }, - }, - }} - componentsProps={{ - paper: { - sx: { - "& .MuiAutocomplete-listbox": { - "& .MuiAutocomplete-option": { - '&[aria-selected="true"]': { - backgroundColor: "transparent !important", - }, - }, - }, - }, - }, - }} - /> + }> = ({ open, onClose, position, setS workspace.customUpdate((draft) => { for (const neuronId of selectedNeurons) { if (!(neuronId in draft.visibilities)) { - draft.visibilities[neuronId] = emptyViewerData(Visibility.Hidden); + draft.visibilities[neuronId] = getDefaultViewerData(Visibility.Hidden); } else { draft.visibilities[neuronId][ViewerType.Graph].visibility = Visibility.Hidden; } @@ -88,7 +88,7 @@ const ContextMenu: React.FC = ({ open, onClose, position, setS workspace.customUpdate((draft) => { // Add the new group draft.neuronGroups[newGroupId] = newGroup; - draft.visibilities[newGroupId] = emptyViewerData(Visibility.Visible); + draft.visibilities[newGroupId] = getDefaultViewerData(Visibility.Visible); // Remove the old groups that were merged into the new group for (const groupId of groupsToDelete) { @@ -195,11 +195,11 @@ const ContextMenu: React.FC = ({ open, onClose, position, setS if (group) { for (const groupedNeuronId of group.neurons) { draft.activeNeurons.add(groupedNeuronId); - draft.visibilities[groupedNeuronId] = emptyViewerData(Visibility.Visible); + draft.visibilities[groupedNeuronId] = getDefaultViewerData(Visibility.Visible); } } else { draft.activeNeurons.add(neuronId); - draft.visibilities[neuronId] = emptyViewerData(Visibility.Visible); + draft.visibilities[neuronId] = getDefaultViewerData(Visibility.Visible); } } }); diff --git a/applications/visualizer/frontend/src/helpers/twoD/splitJoinHelper.ts b/applications/visualizer/frontend/src/helpers/twoD/splitJoinHelper.ts index f07663e8..cfe458fe 100644 --- a/applications/visualizer/frontend/src/helpers/twoD/splitJoinHelper.ts +++ b/applications/visualizer/frontend/src/helpers/twoD/splitJoinHelper.ts @@ -1,5 +1,5 @@ import { ViewerType, type Workspace } from "../../models"; -import { emptyViewerData, type GraphViewerData, Visibility } from "../../models/models.ts"; +import { type GraphViewerData, Visibility, getDefaultViewerData } from "../../models/models.ts"; import { calculateMeanPosition, calculateSplitPositions, isNeuronCell, isNeuronClass } from "./twoDHelpers.ts"; interface SplitJoinState { @@ -69,7 +69,7 @@ export const processNeuronSplit = (workspace: Workspace, splitJoinState: SplitJo for (const [neuronName, update] of Object.entries(graphViewDataUpdates)) { if (!(neuronName in draft.visibilities)) { - draft.visibilities[neuronName] = emptyViewerData(update.visibility); + draft.visibilities[neuronName] = getDefaultViewerData(update.visibility); } if (update.defaultPosition !== undefined) { draft.visibilities[neuronName][ViewerType.Graph].defaultPosition = update.defaultPosition; @@ -147,7 +147,7 @@ export const processNeuronJoin = (workspace: Workspace, splitJoinState: SplitJoi for (const [neuronName, update] of Object.entries(graphViewDataUpdates)) { if (!(neuronName in draft.visibilities)) { - draft.visibilities[neuronName] = emptyViewerData(update.visibility); + draft.visibilities[neuronName] = getDefaultViewerData(update.visibility); } if (update.defaultPosition !== undefined) { draft.visibilities[neuronName][ViewerType.Graph].defaultPosition = update.defaultPosition; diff --git a/applications/visualizer/frontend/src/helpers/twoD/twoDHelpers.ts b/applications/visualizer/frontend/src/helpers/twoD/twoDHelpers.ts index 6eb47f58..280c8356 100644 --- a/applications/visualizer/frontend/src/helpers/twoD/twoDHelpers.ts +++ b/applications/visualizer/frontend/src/helpers/twoD/twoDHelpers.ts @@ -1,6 +1,6 @@ import type { Core, ElementDefinition, Position } from "cytoscape"; import { ViewerType, Visibility, type Workspace } from "../../models"; -import { emptyViewerData } from "../../models/models.ts"; +import { getDefaultViewerData } from "../../models/models.ts"; import type { Connection } from "../../rest"; import { GRAPH_LAYOUTS, LAYOUT_OPTIONS, annotationLegend } from "../../settings/twoDSettings.tsx"; import { cellConfig, neurotransmitterConfig } from "./coloringHelper.ts"; @@ -225,7 +225,7 @@ export const updateWorkspaceNeurons2DViewerData = (workspace: Workspace, cy: Cor for (const node of cy.nodes()) { const neuronId = node.id(); if (!(neuronId in draft.visibilities)) { - draft.visibilities[neuronId] = emptyViewerData(Visibility.Visible); + draft.visibilities[neuronId] = getDefaultViewerData(Visibility.Visible); } draft.visibilities[neuronId][ViewerType.Graph].defaultPosition = { ...node.position() }; draft.visibilities[neuronId][ViewerType.Graph].visibility = Visibility.Visible; diff --git a/applications/visualizer/frontend/src/models/models.ts b/applications/visualizer/frontend/src/models/models.ts index d11e6a10..f726fe5e 100644 --- a/applications/visualizer/frontend/src/models/models.ts +++ b/applications/visualizer/frontend/src/models/models.ts @@ -32,18 +32,27 @@ export interface GraphViewerData { visibility: Visibility; } -export function emptyViewerData(visibility?: Visibility): ViewerData { +export interface ThreeDViewerData { + visibility: Visibility; + color: string; +} + +export function getDefaultViewerData(visibility?: Visibility): ViewerData { return { [ViewerType.Graph]: { defaultPosition: null, visibility: visibility ?? Visibility.Hidden, }, + [ViewerType.ThreeD]: { + visibility: visibility ?? Visibility.Hidden, + color: "#000000", + }, }; } export interface ViewerData { [ViewerType.Graph]?: GraphViewerData; - [ViewerType.ThreeD]?: any; // Define specific data for 3D viewer if needed + [ViewerType.ThreeD]?: ThreeDViewerData; [ViewerType.EM]?: any; // Define specific data for EM viewer if needed [ViewerType.InstanceDetails]?: any; // Define specific data for Instance Details viewer if needed } diff --git a/applications/visualizer/frontend/src/models/workspace.ts b/applications/visualizer/frontend/src/models/workspace.ts index e8db3698..8cef28e5 100644 --- a/applications/visualizer/frontend/src/models/workspace.ts +++ b/applications/visualizer/frontend/src/models/workspace.ts @@ -4,7 +4,7 @@ import { immerable, produce } from "immer"; import getLayoutManagerAndStore from "../layout-manager/layoutManagerFactory"; import { type Dataset, type Neuron, NeuronsService } from "../rest"; import { GlobalError } from "./Error.ts"; -import { type NeuronGroup, type ViewerData, type ViewerSynchronizationPair, ViewerType, Visibility, emptyViewerData } from "./models"; +import { type NeuronGroup, type ViewerData, type ViewerSynchronizationPair, ViewerType, Visibility, getDefaultViewerData } from "./models"; import { type SynchronizerContext, SynchronizerOrchestrator } from "./synchronizer"; export class Workspace { @@ -55,7 +55,7 @@ export class Workspace { this.layoutManager = layoutManager; this.syncOrchestrator = SynchronizerOrchestrator.create(activeSynchronizers, contexts); - this.visibilities = visibilities || Object.fromEntries([...activeNeurons].map((n) => [n, emptyViewerData(Visibility.Visible)])); + this.visibilities = visibilities || Object.fromEntries([...activeNeurons].map((n) => [n, getDefaultViewerData(Visibility.Visible)])); this.store = store; this.updateContext = updateContext; @@ -66,7 +66,7 @@ export class Workspace { activateNeuron(neuron: Neuron): Workspace { const updated = produce(this, (draft: Workspace) => { draft.activeNeurons.add(neuron.name); - draft.visibilities[neuron.name] = emptyViewerData(); + draft.visibilities[neuron.name] = getDefaultViewerData(); }); this.updateContext(updated); return updated; @@ -82,11 +82,12 @@ export class Workspace { hideNeuron(neuronId: string): void { const updated = produce(this, (draft: Workspace) => { if (!(neuronId in draft.visibilities)) { - draft.visibilities[neuronId] = emptyViewerData(Visibility.Hidden); + draft.visibilities[neuronId] = getDefaultViewerData(Visibility.Hidden); draft.removeSelection(neuronId, ViewerType.Graph); } // todo: add actions for other viewers draft.visibilities[neuronId][ViewerType.Graph].visibility = Visibility.Hidden; + draft.visibilities[neuronId][ViewerType.ThreeD].visibility = Visibility.Hidden; }); this.updateContext(updated); } @@ -94,10 +95,11 @@ export class Workspace { showNeuron(neuronId: string): void { const updated = produce(this, (draft: Workspace) => { if (!(neuronId in draft.visibilities)) { - draft.visibilities[neuronId] = emptyViewerData(Visibility.Visible); + draft.visibilities[neuronId] = getDefaultViewerData(Visibility.Visible); } // todo: add actions for other viewers draft.visibilities[neuronId][ViewerType.Graph].visibility = Visibility.Visible; + draft.visibilities[neuronId][ViewerType.ThreeD].visibility = Visibility.Visible; }); this.updateContext(updated); @@ -187,7 +189,7 @@ export class Workspace { const className = neuron.nclass; if (!(className in neuronsClass)) { - const neuronClass = { ...neuron, name: className }; + const neuronClass = { ...neuron, name: className, model3DUrls: [...neuron.model3DUrls], datasetIds: [...neuron.datasetIds] }; neuronsClass[className] = neuronClass; uniqueNeurons.add(neuronClass); } else { @@ -251,4 +253,8 @@ export class Workspace { const neuron = this.availableNeurons[neuronId]; return neuron.nclass; } + + getVisibleNeuronsInThreeD(): string[] { + return Array.from(this.activeNeurons).filter((neuronId) => this.visibilities[neuronId]?.[ViewerType.ThreeD]?.visibility === Visibility.Visible); + } }