From 55866cd36819c6db73360cfb9f522afee1187a73 Mon Sep 17 00:00:00 2001 From: salam dalloul Date: Thu, 26 Sep 2024 22:11:40 +0200 Subject: [PATCH 1/7] #93 implement 3D Screenshot functionality --- applications/visualizer/frontend/package.json | 1 + .../components/viewers/ThreeD/Screenshoter.ts | 60 +++++++++++++++++++ .../viewers/ThreeD/ThreeDViewer.tsx | 20 +++++-- 3 files changed, 76 insertions(+), 5 deletions(-) create mode 100644 applications/visualizer/frontend/src/components/viewers/ThreeD/Screenshoter.ts diff --git a/applications/visualizer/frontend/package.json b/applications/visualizer/frontend/package.json index 36c763a4..b93bdd28 100644 --- a/applications/visualizer/frontend/package.json +++ b/applications/visualizer/frontend/package.json @@ -31,6 +31,7 @@ "cytoscape-dagre": "^2.5.0", "cytoscape-fcose": "^2.2.0", "file-saver": "^2.0.5", + "html-to-image": "^1.11.11", "html2canvas": "^1.4.1", "ol": "^9.1.0", "pako": "^2.1.0", diff --git a/applications/visualizer/frontend/src/components/viewers/ThreeD/Screenshoter.ts b/applications/visualizer/frontend/src/components/viewers/ThreeD/Screenshoter.ts new file mode 100644 index 00000000..182784e5 --- /dev/null +++ b/applications/visualizer/frontend/src/components/viewers/ThreeD/Screenshoter.ts @@ -0,0 +1,60 @@ +import * as htmlToImage from "html-to-image"; + +export function formatDate(d) { + return `${d.getFullYear()}${d.getMonth() + 1}${d.getDate()}-${pad(d.getHours(), 2)}${pad(d.getMinutes(), 2)}${pad(d.getSeconds(), 2)}`; +} + +function pad(num, size) { + let s = num + ""; + while (s.length < size) { + s = "0" + s; + } + return s; +} +function getOptions(htmlElement, targetResolution, quality, pixelRatio, filter) { + const resolution = getResolutionFixedRatio(htmlElement, targetResolution); + return { + quality: quality, + canvasWidth: resolution.width, + canvasHeight: resolution.height, + pixelRatio: pixelRatio, + filter: filter, + }; +} + +export function downloadScreenshot( + htmlElement, + quality = 0.95, + targetResolution = { width: 3840, height: 2160 }, + pixelRatio = 1, + filter = () => true, + filename = `Canvas_${formatDate(new Date())}.png`, +) { + const options = getOptions(htmlElement, targetResolution, quality, pixelRatio, filter); + + // Use `toBlob` to capture the canvas content as a blob + htmlToImage.toBlob(htmlElement, options).then((blob) => { + const link = document.createElement("a"); + link.download = filename; + link.href = window.URL.createObjectURL(blob); + link.click(); + }); +} + +function getResolutionFixedRatio(htmlElement, target) { + const current = { + height: htmlElement.clientHeight, + width: htmlElement.clientWidth, + }; + // if height is closer + if ((Math.abs(target.width - current.width) * 9) / 16 > Math.abs(target.height - current.height)) { + return { + height: target.height, + width: Math.round((current.width * target.height) / current.height), + }; + } + return { + height: Math.round((current.height * target.width) / current.width), + width: target.width, + }; +} diff --git a/applications/visualizer/frontend/src/components/viewers/ThreeD/ThreeDViewer.tsx b/applications/visualizer/frontend/src/components/viewers/ThreeD/ThreeDViewer.tsx index 5aa9e3a4..84516879 100644 --- a/applications/visualizer/frontend/src/components/viewers/ThreeD/ThreeDViewer.tsx +++ b/applications/visualizer/frontend/src/components/viewers/ThreeD/ThreeDViewer.tsx @@ -1,6 +1,7 @@ +import { Box } from "@mui/system"; import { CameraControls, PerspectiveCamera } from "@react-three/drei"; import { Canvas } from "@react-three/fiber"; -import { Suspense, useEffect, useMemo, useRef, useState } from "react"; +import { Suspense, createRef, 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"; @@ -19,6 +20,7 @@ import Gizmo from "./Gizmo.tsx"; import Loader from "./Loader.tsx"; import STLViewer from "./STLViewer.tsx"; import SceneControls from "./SceneControls.tsx"; +import { downloadScreenshot } from "./Screenshoter.ts"; export interface Instance { id: string; @@ -37,6 +39,8 @@ function ThreeDViewer() { const cameraControlRef = useRef(null); + const ref = createRef(); + // @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); @@ -64,10 +68,16 @@ function ThreeDViewer() { setInstances(newInstances); }, [selectedDataset, workspace.availableNeurons, workspace.visibilities]); + const handleScreenshot = () => { + if (ref.current) { + // Use the Screenshoter utility functionthis.sceneRef.current && this.sceneRef.current.getElementsByTagName('canvas')[0] + downloadScreenshot(ref.current && ref.current.getElementsByTagName("canvas")[0], 0.95, { width: 3840, height: 2160 }, 1, () => true, "screenshot.png"); + } + }; return ( - <> + - + }> - - + + ); } From 24c01bfac615b36fc8936680abb7f12bd83ce7bd Mon Sep 17 00:00:00 2001 From: salam dalloul Date: Thu, 26 Sep 2024 22:22:57 +0200 Subject: [PATCH 2/7] #93 implement 3D Screenshot functionality --- .../frontend/src/components/viewers/ThreeD/SceneControls.tsx | 4 ++-- .../frontend/src/components/viewers/ThreeD/ThreeDViewer.tsx | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/applications/visualizer/frontend/src/components/viewers/ThreeD/SceneControls.tsx b/applications/visualizer/frontend/src/components/viewers/ThreeD/SceneControls.tsx index 765f0bb8..be459dfa 100644 --- a/applications/visualizer/frontend/src/components/viewers/ThreeD/SceneControls.tsx +++ b/applications/visualizer/frontend/src/components/viewers/ThreeD/SceneControls.tsx @@ -9,7 +9,7 @@ import CustomFormControlLabel from "./CustomFormControlLabel.tsx"; const { gray500 } = vars; -function SceneControls({ cameraControlRef, isWireframe, setIsWireframe }) { +function SceneControls({ cameraControlRef, isWireframe, setIsWireframe, handleScreenshot }) { const [anchorEl, setAnchorEl] = useState(null); const open = Boolean(anchorEl); @@ -129,7 +129,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 84516879..02e9e387 100644 --- a/applications/visualizer/frontend/src/components/viewers/ThreeD/ThreeDViewer.tsx +++ b/applications/visualizer/frontend/src/components/viewers/ThreeD/ThreeDViewer.tsx @@ -70,7 +70,6 @@ function ThreeDViewer() { const handleScreenshot = () => { if (ref.current) { - // Use the Screenshoter utility functionthis.sceneRef.current && this.sceneRef.current.getElementsByTagName('canvas')[0] downloadScreenshot(ref.current && ref.current.getElementsByTagName("canvas")[0], 0.95, { width: 3840, height: 2160 }, 1, () => true, "screenshot.png"); } }; From 98fb187718902973ad6b4e8bceac9bfe8b63c9ba Mon Sep 17 00:00:00 2001 From: salam dalloul Date: Fri, 27 Sep 2024 13:18:45 +0200 Subject: [PATCH 3/7] #93 implement 3D video recording --- .../src/components/viewers/ThreeD/Recorder.ts | 115 ++++++++++++++++++ .../viewers/ThreeD/SceneControls.tsx | 22 +++- .../components/viewers/ThreeD/Screenshoter.ts | 15 +-- .../viewers/ThreeD/ThreeDViewer.tsx | 46 +++++-- .../visualizer/frontend/src/helpers/utils.ts | 12 ++ 5 files changed, 184 insertions(+), 26 deletions(-) create mode 100644 applications/visualizer/frontend/src/components/viewers/ThreeD/Recorder.ts diff --git a/applications/visualizer/frontend/src/components/viewers/ThreeD/Recorder.ts b/applications/visualizer/frontend/src/components/viewers/ThreeD/Recorder.ts new file mode 100644 index 00000000..cc49f6bb --- /dev/null +++ b/applications/visualizer/frontend/src/components/viewers/ThreeD/Recorder.ts @@ -0,0 +1,115 @@ +import { formatDate } from "../../../helpers/utils.ts"; +export class Recorder { + private mediaRecorder: MediaRecorder | null = null; + private recordedBlobs: Blob[] = []; + private stream: MediaStream; + private ctx: WebGLRenderingContext; + // @ts-ignore + private options: { mediaRecorderOptions?: MediaRecorderOptions; blobOptions?: BlobPropertyBag } = { + mediaRecorderOptions: { mimeType: "video/webm" }, + blobOptions: { type: "video/webm" }, + }; + private blobOptions: BlobPropertyBag = { type: "video/webm" }; + + constructor(canvas: HTMLCanvasElement, recorderOptions: { mediaRecorderOptions?: MediaRecorderOptions; blobOptions?: BlobPropertyBag }) { + this.stream = canvas.captureStream(); + const { mediaRecorderOptions, blobOptions } = recorderOptions; + this.setupMediaRecorder(mediaRecorderOptions); + this.recordedBlobs = []; + this.blobOptions = blobOptions; + this.ctx = canvas.getContext("webgl", { alpha: true, antialias: true, preserveDrawingBuffer: true }); + } + + handleDataAvailable(event) { + if (event.data && event.data.size > 0) { + this.recordedBlobs.push(event.data); + } + } + + setupMediaRecorder(options) { + let error = ""; + + if (options == null) { + options = { mimeType: "video/webm" }; + } + let mediaRecorder; + try { + mediaRecorder = new MediaRecorder(this.stream, options); + } catch (e0) { + error = `Unable to create MediaRecorder with options Object: ${e0}`; + try { + options = { mimeType: "video/webm,codecs=vp9" }; + mediaRecorder = new MediaRecorder(this.stream, options); + } catch (e1) { + error = `Unable to create MediaRecorder with options Object: ${e1}`; + try { + options = { mimeType: "video/webm,codecs=vp8" }; // Chrome 47 + mediaRecorder = new MediaRecorder(this.stream, options); + } catch (e2) { + error = + "MediaRecorder is not supported by this browser.\n\n" + + "Try Firefox 29 or later, or Chrome 47 or later, " + + "with Enable experimental Web Platform features enabled from chrome://flags." + + `Exception while creating MediaRecorder: ${e2}`; + } + } + } + + if (!mediaRecorder) { + throw new Error(error); + } + + mediaRecorder.ondataavailable = (evt) => this.handleDataAvailable(evt); + mediaRecorder.onstart = () => this.animationLoop(); + + this.mediaRecorder = mediaRecorder; + this.options = options; + if (!this.blobOptions) { + const { mimeType } = options; + this.blobOptions = { type: mimeType }; + } + } + + startRecording() { + this.recordedBlobs = []; + this.mediaRecorder.start(100); + } + + stopRecording(options) { + this.mediaRecorder.stop(); + return this.getRecordingBlob(options); + } + + download(filename, options) { + if (!filename) { + filename = `CanvasRecording_${formatDate(new Date())}.webm`; + } + const blob = this.getRecordingBlob(options); + const url = window.URL.createObjectURL(blob); + const a = document.createElement("a"); + a.style.display = "none"; + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + setTimeout(() => { + document.body.removeChild(a); + window.URL.revokeObjectURL(url); + }, 100); + return blob; + } + + getRecordingBlob(options) { + if (!options) { + options = this.blobOptions; + } + return new Blob(this.recordedBlobs, options); + } + + animationLoop() { + this.ctx.drawArrays(this.ctx.POINTS, 0, 0); + if (this.mediaRecorder.state !== "inactive") { + requestAnimationFrame(this.animationLoop); + } + } +} diff --git a/applications/visualizer/frontend/src/components/viewers/ThreeD/SceneControls.tsx b/applications/visualizer/frontend/src/components/viewers/ThreeD/SceneControls.tsx index be459dfa..29a4e570 100644 --- a/applications/visualizer/frontend/src/components/viewers/ThreeD/SceneControls.tsx +++ b/applications/visualizer/frontend/src/components/viewers/ThreeD/SceneControls.tsx @@ -9,8 +9,18 @@ import CustomFormControlLabel from "./CustomFormControlLabel.tsx"; const { gray500 } = vars; -function SceneControls({ cameraControlRef, isWireframe, setIsWireframe, handleScreenshot }) { +function SceneControls({ cameraControlRef, isWireframe, setIsWireframe, handleScreenshot, startRecording, stopRecording }) { const [anchorEl, setAnchorEl] = useState(null); + const [isRecording, setIsRecording] = useState(false); + + const handleRecordClick = () => { + if (isRecording) { + stopRecording(); + } else { + startRecording(); + } + setIsRecording(!isRecording); + }; const open = Boolean(anchorEl); const id = open ? "settings-popover" : undefined; @@ -123,9 +133,13 @@ function SceneControls({ cameraControlRef, isWireframe, setIsWireframe, handleSc - - - + + + diff --git a/applications/visualizer/frontend/src/components/viewers/ThreeD/Screenshoter.ts b/applications/visualizer/frontend/src/components/viewers/ThreeD/Screenshoter.ts index 182784e5..4626555c 100644 --- a/applications/visualizer/frontend/src/components/viewers/ThreeD/Screenshoter.ts +++ b/applications/visualizer/frontend/src/components/viewers/ThreeD/Screenshoter.ts @@ -1,16 +1,6 @@ import * as htmlToImage from "html-to-image"; +import { formatDate } from "../../../helpers/utils.ts"; -export function formatDate(d) { - return `${d.getFullYear()}${d.getMonth() + 1}${d.getDate()}-${pad(d.getHours(), 2)}${pad(d.getMinutes(), 2)}${pad(d.getSeconds(), 2)}`; -} - -function pad(num, size) { - let s = num + ""; - while (s.length < size) { - s = "0" + s; - } - return s; -} function getOptions(htmlElement, targetResolution, quality, pixelRatio, filter) { const resolution = getResolutionFixedRatio(htmlElement, targetResolution); return { @@ -32,7 +22,6 @@ export function downloadScreenshot( ) { const options = getOptions(htmlElement, targetResolution, quality, pixelRatio, filter); - // Use `toBlob` to capture the canvas content as a blob htmlToImage.toBlob(htmlElement, options).then((blob) => { const link = document.createElement("a"); link.download = filename; @@ -46,7 +35,7 @@ function getResolutionFixedRatio(htmlElement, target) { height: htmlElement.clientHeight, width: htmlElement.clientWidth, }; - // if height is closer + if ((Math.abs(target.width - current.width) * 9) / 16 > Math.abs(target.height - current.height)) { return { height: target.height, diff --git a/applications/visualizer/frontend/src/components/viewers/ThreeD/ThreeDViewer.tsx b/applications/visualizer/frontend/src/components/viewers/ThreeD/ThreeDViewer.tsx index 02e9e387..95c44d7d 100644 --- a/applications/visualizer/frontend/src/components/viewers/ThreeD/ThreeDViewer.tsx +++ b/applications/visualizer/frontend/src/components/viewers/ThreeD/ThreeDViewer.tsx @@ -1,7 +1,6 @@ -import { Box } from "@mui/system"; import { CameraControls, PerspectiveCamera } from "@react-three/drei"; import { Canvas } from "@react-three/fiber"; -import { Suspense, createRef, useEffect, useMemo, useRef, useState } from "react"; +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"; @@ -18,6 +17,7 @@ import { import DatasetPicker from "./DatasetPicker.tsx"; import Gizmo from "./Gizmo.tsx"; import Loader from "./Loader.tsx"; +import { Recorder } from "./Recorder"; import STLViewer from "./STLViewer.tsx"; import SceneControls from "./SceneControls.tsx"; import { downloadScreenshot } from "./Screenshoter.ts"; @@ -39,7 +39,7 @@ function ThreeDViewer() { const cameraControlRef = useRef(null); - const ref = createRef(); + const recorderRef = useRef(null); // @ts-expect-error 'setShowNeurons' is declared but its value is never read. // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -69,14 +69,35 @@ function ThreeDViewer() { }, [selectedDataset, workspace.availableNeurons, workspace.visibilities]); const handleScreenshot = () => { - if (ref.current) { - downloadScreenshot(ref.current && ref.current.getElementsByTagName("canvas")[0], 0.95, { width: 3840, height: 2160 }, 1, () => true, "screenshot.png"); + if (cameraControlRef.current) { + downloadScreenshot(document.getElementsByTagName("canvas")[0], 0.95, { width: 3840, height: 2160 }, 1, () => true, "screenshot.png"); } }; + + const startRecording = () => { + if (recorderRef.current === null) { + const canvas = document.getElementsByTagName("canvas")[0]; + recorderRef.current = new Recorder(canvas, { + mediaRecorderOptions: { mimeType: "video/webm" }, + blobOptions: { type: "video/webm" }, + }); + recorderRef.current.startRecording(); + } + }; + + const stopRecording = async () => { + if (recorderRef.current) { + recorderRef.current.stopRecording({ type: "video/webm" }); + recorderRef.current.download("CanvasRecording.webm", { type: "video/webm" }); + recorderRef.current = null; + } + }; + return ( - + <> - + + }> - - + + ); } diff --git a/applications/visualizer/frontend/src/helpers/utils.ts b/applications/visualizer/frontend/src/helpers/utils.ts index 908da339..e650ceab 100644 --- a/applications/visualizer/frontend/src/helpers/utils.ts +++ b/applications/visualizer/frontend/src/helpers/utils.ts @@ -9,3 +9,15 @@ export function areSetsEqual(setA: Set, setB: Set): boolean { } return true; } + +export function formatDate(d) { + return `${d.getFullYear()}${d.getMonth() + 1}${d.getDate()}-${pad(d.getHours(), 2)}${pad(d.getMinutes(), 2)}${pad(d.getSeconds(), 2)}`; +} + +function pad(num, size) { + let s = num + ""; + while (s.length < size) { + s = "0" + s; + } + return s; +} From 6b6874e13e1dd8b31ba97c02805af2362570ea10 Mon Sep 17 00:00:00 2001 From: salam dalloul Date: Fri, 27 Sep 2024 13:27:37 +0200 Subject: [PATCH 4/7] move SceneControls functions to the SceneControls file --- .../viewers/ThreeD/SceneControls.tsx | 29 ++++++++++++++- .../viewers/ThreeD/ThreeDViewer.tsx | 37 +------------------ 2 files changed, 30 insertions(+), 36 deletions(-) diff --git a/applications/visualizer/frontend/src/components/viewers/ThreeD/SceneControls.tsx b/applications/visualizer/frontend/src/components/viewers/ThreeD/SceneControls.tsx index 29a4e570..7fb0e625 100644 --- a/applications/visualizer/frontend/src/components/viewers/ThreeD/SceneControls.tsx +++ b/applications/visualizer/frontend/src/components/viewers/ThreeD/SceneControls.tsx @@ -6,10 +6,12 @@ import Tooltip from "@mui/material/Tooltip"; import { useState } from "react"; import { vars } from "../../../theme/variables.ts"; import CustomFormControlLabel from "./CustomFormControlLabel.tsx"; +import { Recorder } from "./Recorder.ts"; +import { downloadScreenshot } from "./Screenshoter.ts"; const { gray500 } = vars; -function SceneControls({ cameraControlRef, isWireframe, setIsWireframe, handleScreenshot, startRecording, stopRecording }) { +function SceneControls({ cameraControlRef, isWireframe, setIsWireframe, recorderRef }) { const [anchorEl, setAnchorEl] = useState(null); const [isRecording, setIsRecording] = useState(false); @@ -32,6 +34,31 @@ function SceneControls({ cameraControlRef, isWireframe, setIsWireframe, handleSc setAnchorEl(null); }; + const handleScreenshot = () => { + if (cameraControlRef.current) { + downloadScreenshot(document.getElementsByTagName("canvas")[0], 0.95, { width: 3840, height: 2160 }, 1, () => true, "screenshot.png"); + } + }; + + const startRecording = () => { + if (recorderRef.current === null) { + const canvas = document.getElementsByTagName("canvas")[0]; + recorderRef.current = new Recorder(canvas, { + mediaRecorderOptions: { mimeType: "video/webm" }, + blobOptions: { type: "video/webm" }, + }); + recorderRef.current.startRecording(); + } + }; + + const stopRecording = async () => { + if (recorderRef.current) { + recorderRef.current.stopRecording({ type: "video/webm" }); + recorderRef.current.download("CanvasRecording.webm", { type: "video/webm" }); + recorderRef.current = null; + } + }; + return ( { - if (cameraControlRef.current) { - downloadScreenshot(document.getElementsByTagName("canvas")[0], 0.95, { width: 3840, height: 2160 }, 1, () => true, "screenshot.png"); - } - }; - - const startRecording = () => { - if (recorderRef.current === null) { - const canvas = document.getElementsByTagName("canvas")[0]; - recorderRef.current = new Recorder(canvas, { - mediaRecorderOptions: { mimeType: "video/webm" }, - blobOptions: { type: "video/webm" }, - }); - recorderRef.current.startRecording(); - } - }; - - const stopRecording = async () => { - if (recorderRef.current) { - recorderRef.current.stopRecording({ type: "video/webm" }); - recorderRef.current.download("CanvasRecording.webm", { type: "video/webm" }); - recorderRef.current = null; - } - }; - return ( <> @@ -117,14 +91,7 @@ function ThreeDViewer() { - + ); } From 4c10dcabdc7aa12992ed46af6e0f4d49562171cc Mon Sep 17 00:00:00 2001 From: salam dalloul Date: Tue, 1 Oct 2024 15:17:04 +0200 Subject: [PATCH 5/7] remove unused code --- .../frontend/src/components/viewers/ThreeD/ThreeDViewer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/applications/visualizer/frontend/src/components/viewers/ThreeD/ThreeDViewer.tsx b/applications/visualizer/frontend/src/components/viewers/ThreeD/ThreeDViewer.tsx index a78e5966..15909c60 100644 --- a/applications/visualizer/frontend/src/components/viewers/ThreeD/ThreeDViewer.tsx +++ b/applications/visualizer/frontend/src/components/viewers/ThreeD/ThreeDViewer.tsx @@ -70,7 +70,7 @@ function ThreeDViewer() { return ( <> - + }> Date: Tue, 1 Oct 2024 15:23:10 +0200 Subject: [PATCH 6/7] remove unused code --- .../frontend/src/components/viewers/ThreeD/Recorder.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/applications/visualizer/frontend/src/components/viewers/ThreeD/Recorder.ts b/applications/visualizer/frontend/src/components/viewers/ThreeD/Recorder.ts index cc49f6bb..e2171524 100644 --- a/applications/visualizer/frontend/src/components/viewers/ThreeD/Recorder.ts +++ b/applications/visualizer/frontend/src/components/viewers/ThreeD/Recorder.ts @@ -17,7 +17,7 @@ export class Recorder { this.setupMediaRecorder(mediaRecorderOptions); this.recordedBlobs = []; this.blobOptions = blobOptions; - this.ctx = canvas.getContext("webgl", { alpha: true, antialias: true, preserveDrawingBuffer: true }); + this.ctx = canvas.getContext("webgl"); } handleDataAvailable(event) { From 81138da0f45f02bc65db701eda5aba30d4c5b5c1 Mon Sep 17 00:00:00 2001 From: salam dalloul Date: Tue, 1 Oct 2024 19:50:15 +0200 Subject: [PATCH 7/7] resolve conflict --- .../frontend/src/components/viewers/ThreeD/ThreeDViewer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/applications/visualizer/frontend/src/components/viewers/ThreeD/ThreeDViewer.tsx b/applications/visualizer/frontend/src/components/viewers/ThreeD/ThreeDViewer.tsx index 15909c60..e2dcdeec 100644 --- a/applications/visualizer/frontend/src/components/viewers/ThreeD/ThreeDViewer.tsx +++ b/applications/visualizer/frontend/src/components/viewers/ThreeD/ThreeDViewer.tsx @@ -70,7 +70,7 @@ function ThreeDViewer() { return ( <> - + }>