diff --git a/app/packages/core/src/components/Modal/Modal.tsx b/app/packages/core/src/components/Modal/Modal.tsx index f8d4d92a292..df0ea91bf14 100644 --- a/app/packages/core/src/components/Modal/Modal.tsx +++ b/app/packages/core/src/components/Modal/Modal.tsx @@ -113,6 +113,10 @@ const Modal = () => { } } + if (e.repeat) { + return; + } + if (e.altKey && e.code === "Space") { const hoveringSampleId = ( await snapshot.getPromise(fos.hoveredSample) @@ -145,7 +149,10 @@ const Modal = () => { }); } else if (e.key === "Escape") { const mediaType = await snapshot.getPromise(fos.mediaType); - if (activeLookerRef.current || mediaType === "3d") { + const is3dVisible = await snapshot.getPromise( + fos.groupMediaIs3dVisible + ); + if (activeLookerRef.current || mediaType === "3d" || is3dVisible) { // we handle close logic in modal + other places return; } @@ -156,7 +163,7 @@ const Modal = () => { [] ); - fos.useEventHandler(document, "keyup", keysHandler); + fos.useEventHandler(document, "keydown", keysHandler); const isFullScreen = useRecoilValue(fos.fullscreen); diff --git a/app/packages/core/src/components/Modal/Sidebar/Annotate/Annotate.tsx b/app/packages/core/src/components/Modal/Sidebar/Annotate/Annotate.tsx index 5493fa58ee5..f41c0efba97 100644 --- a/app/packages/core/src/components/Modal/Sidebar/Annotate/Annotate.tsx +++ b/app/packages/core/src/components/Modal/Sidebar/Annotate/Annotate.tsx @@ -1,9 +1,10 @@ import { LoadingSpinner } from "@fiftyone/components"; import { lighterSceneAtom } from "@fiftyone/lighter"; +import * as fos from "@fiftyone/state"; import { EntryKind } from "@fiftyone/state"; import { Typography } from "@mui/material"; import { atom, useAtomValue } from "jotai"; -import React from "react"; +import { useRecoilValue } from "recoil"; import styled from "styled-components"; import Sidebar from "../../../Sidebar"; import Actions from "./Actions"; @@ -85,7 +86,9 @@ const Annotate = () => { const editing = useAtomValue(isEditing); const scene = useAtomValue(lighterSceneAtom); - if (loading || !scene) { + const mediaType = useRecoilValue(fos.mediaType); + + if (loading || (!scene && mediaType !== "3d")) { return ; } diff --git a/app/packages/looker-3d/package.json b/app/packages/looker-3d/package.json index e51c137d178..12a58af1e6a 100644 --- a/app/packages/looker-3d/package.json +++ b/app/packages/looker-3d/package.json @@ -16,8 +16,9 @@ "dependencies": { "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.0", - "@react-three/drei": "^10.6.0", + "@react-three/drei": "^10.7.6", "@react-three/fiber": "^8.18.0", + "chroma-js": "^3.1.2", "leva": "^0.9.36", "lodash": "^4.17.21", "r3f-perf": "^7.2.3", @@ -28,6 +29,7 @@ }, "devDependencies": { "@biomejs/biome": "^1.8.3", + "@types/chroma-js": "^3.1.1", "@types/node": "^20.11.10", "@types/react": "^18.2.48", "@types/react-dom": "^18.2.18", diff --git a/app/packages/looker-3d/src/Looker3d.tsx b/app/packages/looker-3d/src/Looker3d.tsx index ebbe8b278dc..fcb960cfdef 100644 --- a/app/packages/looker-3d/src/Looker3d.tsx +++ b/app/packages/looker-3d/src/Looker3d.tsx @@ -4,21 +4,24 @@ import { isPointCloud, setContainsPointCloud, } from "@fiftyone/utilities"; +import { useAtomValue } from "jotai"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useRecoilValue, useSetRecoilState } from "recoil"; -import { Fo3dErrorBoundary } from "./ErrorBoundary"; -import { MediaTypePcdComponent } from "./MediaTypePcd"; import { ActionBar } from "./action-bar"; import { Container } from "./containers"; +import { Fo3dErrorBoundary } from "./ErrorBoundary"; import { Leva } from "./fo3d/Leva"; import { MediaTypeFo3dComponent } from "./fo3d/MediaTypeFo3d"; import { useHotkey } from "./hooks"; +import { MediaTypePcdComponent } from "./MediaTypePcd"; import { + clearTransformStateSelector, currentActionAtom, fo3dContainsBackground, isColormapModalOpenAtom, isGridOnAtom, isLevaConfigPanelOnAtom, + selectedLabelForAnnotationAtom, } from "./state"; /** @@ -36,6 +39,7 @@ export const Looker3d = () => { ); const isDynamicGroup = useRecoilValue(fos.isDynamicGroup); const parentMediaType = useRecoilValue(fos.parentMediaTypeSelector); + const mode = useAtomValue(fos.modalMode); const [isHovering, setIsHovering] = useState(false); const timeout = useRef>(null); diff --git a/app/packages/looker-3d/src/MediaTypePcd.tsx b/app/packages/looker-3d/src/MediaTypePcd.tsx index c64b4e811e2..4176b8ccfd7 100644 --- a/app/packages/looker-3d/src/MediaTypePcd.tsx +++ b/app/packages/looker-3d/src/MediaTypePcd.tsx @@ -1,6 +1,7 @@ import { useTheme } from "@fiftyone/components"; import * as fop from "@fiftyone/plugins"; import * as fos from "@fiftyone/state"; +import { isPointCloud } from "@fiftyone/utilities"; import { Typography } from "@mui/material"; import type { OrbitControlsProps as OrbitControls } from "@react-three/drei"; import { Canvas } from "@react-three/fiber"; @@ -32,7 +33,6 @@ import { shadeByAtom, } from "./state"; import { toEulerFromDegreesArray } from "./utils"; -import { isPointCloud } from "@fiftyone/utilities"; type View = "pov" | "top"; diff --git a/app/packages/looker-3d/src/StatusBar.tsx b/app/packages/looker-3d/src/StatusBar.tsx index 018ff3715f1..920fec30783 100644 --- a/app/packages/looker-3d/src/StatusBar.tsx +++ b/app/packages/looker-3d/src/StatusBar.tsx @@ -1,8 +1,9 @@ -import { IconButton, InfoIcon } from "@fiftyone/components"; +import { IconButton, InfoIcon, useTheme } from "@fiftyone/components"; import { Close } from "@mui/icons-material"; import BubbleChartIcon from "@mui/icons-material/BubbleChart"; import CallSplitIcon from "@mui/icons-material/CallSplit"; import CodeIcon from "@mui/icons-material/Code"; +import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; import LayersIcon from "@mui/icons-material/Layers"; import SpeedIcon from "@mui/icons-material/Speed"; import TextureIcon from "@mui/icons-material/Texture"; @@ -12,7 +13,6 @@ import Text from "@mui/material/Typography"; import { animated, useSpring } from "@react-spring/web"; import { getPerf, PerfHeadless } from "r3f-perf"; import { - CSSProperties, type RefObject, useCallback, useEffect, @@ -20,17 +20,135 @@ import { useState, } from "react"; import { useRecoilState, useSetRecoilState } from "recoil"; -import type { PerspectiveCamera, Vector3 } from "three"; +import styled from "styled-components"; +import type { OrthographicCamera, PerspectiveCamera, Vector3 } from "three"; import tunnel from "tunnel-rat"; import { StatusBarContainer } from "./containers"; -import { activeNodeAtom, isStatusBarOnAtom } from "./state"; +import { + activeNodeAtom, + isStatusBarOnAtom, + segmentPolylineStateAtom, +} from "./state"; + +const PerfContainer = styled.div` + position: fixed; + bottom: 0; + right: 2em; + background: rgba(40, 44, 52, 0.85); + opacity: 0.6; + border-radius: 8px; + padding: 16px 24px 12px 24px; + min-width: 240px; + box-shadow: none; + backdrop-filter: blur(4px); + border: 1px solid rgba(255, 255, 255, 0.1); + z-index: 1000; + color: #e0e0e0; + display: flex; + flex-direction: column; + gap: 8px; + font-size: 12px; + letter-spacing: 0.01em; + align-items: stretch; +`; + +const StatRow = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5em; + width: 100%; + min-height: 28px; +`; + +const StatLabel = styled.span` + display: flex; + align-items: center; + gap: 0.4em; + font-weight: 400; + opacity: 0.85; + font-size: 14px; +`; + +const StatValue = styled.span` + font-weight: 600; + font-variant-numeric: tabular-nums; + color: #bdbdbd; + font-size: 14px; + min-width: 60px; + text-align: right; +`; + +const StatBarTrack = styled.div` + width: 100%; + height: 6px; + background: rgba(255, 255, 255, 0.07); + border-radius: 3px; + margin-top: 2px; + margin-bottom: 2px; + overflow: hidden; +`; + +const FpsHeader = styled(StatRow)<{ $color: string }>` + justify-content: center; + font-size: 15px; + font-weight: 700; + color: ${(p) => p.$color}; +`; + +const SegmentHint = styled.div<{ $border: string; $text: string }>` + position: fixed; + top: 0; + left: 50%; + transform: translateX(-50%); + opacity: 0.6; + color: ${(p) => p.$text}; + padding: 8px 12px; + border-radius: 4px; + font-size: 12px; + font-weight: 400; + z-index: 1000; + border: 1px solid ${(p) => p.$border}; + max-width: 340px; + user-select: none; + pointer-events: none; +`; + +const SegmentHintRow = styled.div` + display: flex; + align-items: center; + gap: 6px; + font-size: 10px; +`; + +const CloseBar = styled.div<{ $bg: string }>` + display: flex; + width: 100%; + justify-content: right; + background-color: ${(p) => p.$bg}; +`; + +const PerfPanel = styled.div<{ $bg: string }>` + display: flex; + flex-direction: column; + padding-left: 1em; + justify-content: space-between; + position: relative; + height: 100%; + width: 100%; + background-color: ${(p) => p.$bg}; +`; + +const MutedIconButton = styled(IconButton)` + opacity: 0.5; +`; export const StatusTunnel = tunnel(); const CameraInfo = ({ cameraRef, }: { - cameraRef: RefObject; + cameraRef: RefObject; }) => { const [cameraPosition, setCameraPosition] = useState(); @@ -101,66 +219,6 @@ const PerfStats = () => { return () => clearInterval(interval); }, []); - const containerStyle: CSSProperties = { - position: "fixed", - bottom: "0", - right: "2em", - background: "rgba(40, 44, 52, 0.85)", - opacity: 0.6, - borderRadius: "8px", - padding: "16px 24px 12px 24px", - minWidth: "240px", - boxShadow: "none", - backdropFilter: "blur(4px)", - border: "1px solid rgba(255,255,255,0.10)", - zIndex: 1000, - color: "#e0e0e0", - display: "flex", - flexDirection: "column", - gap: "8px", - // fontFamily: "'Inter', 'Roboto', 'Arial', sans-serif", - fontSize: "12px", - letterSpacing: "0.01em", - alignItems: "stretch", - }; - - const statRowStyle: CSSProperties = { - display: "flex", - alignItems: "center", - justifyContent: "space-between", - gap: "0.5em", - width: "100%", - minHeight: 28, - }; - - const statLabelStyle: CSSProperties = { - display: "flex", - alignItems: "center", - gap: "0.4em", - fontWeight: 400, - opacity: 0.85, - fontSize: "14px", - }; - - const statValueStyle: CSSProperties = { - fontWeight: 600, - fontVariantNumeric: "tabular-nums", - color: "#bdbdbd", - fontSize: "14px", - minWidth: 60, - textAlign: "right" as const, - }; - - const statBarContainer: CSSProperties = { - width: "100%", - height: 6, - background: "rgba(255,255,255,0.07)", - borderRadius: 3, - marginTop: 2, - marginBottom: 2, - overflow: "hidden", - }; - const statBarColors = { // blue calls: "#38bdf8", @@ -190,7 +248,7 @@ const PerfStats = () => { const fpsColor = perfStats.fps > 50 ? "#4ade80" : perfStats.fps > 30 ? "#facc15" : "#f87171"; - const StatRow = ({ + const StatRowItem = ({ icon, label, value, @@ -202,14 +260,14 @@ const PerfStats = () => { barKey: keyof typeof statBarColors; }) => (
-
- + + {icon} {label} - - {value.toLocaleString()} -
-
+ + {value.toLocaleString()} + +
{ transition: "width 0.4s cubic-bezier(.4,2,.6,1)", }} /> -
+
); return ( -
-
+ + FPS{" "} - + {perfStats.fps.toFixed(1)} - -
+ +
{ margin: "4px 0 2px 0", }} /> - { value={perfStats.calls} barKey="calls" /> - { value={perfStats.triangles} barKey="triangles" /> - { value={perfStats.points} barKey="points" /> - { value={perfStats.geometries} barKey="geometries" /> - { value={perfStats.textures} barKey="textures" /> - { value={perfStats.programs} barKey="programs" /> -
+ ); }; export const StatusBar = ({ cameraRef, }: { - cameraRef: RefObject; + cameraRef: RefObject; }) => { + const theme = useTheme(); const containerRef = useRef(null); const [showPerfStatus, setShowPerfStatus] = useRecoilState(isStatusBarOnAtom); const setActiveNode = useSetRecoilState(activeNodeAtom); + const segmentPolylineState = useRecoilState(segmentPolylineStateAtom)[0]; const springProps = useSpring({ transform: showPerfStatus ? "translateY(10%)" : "translateY(0%)", @@ -339,44 +391,38 @@ export const StatusBar = ({ return ( {!showPerfStatus && ( - + - + + )} + + {segmentPolylineState.isActive && ( + + + + Snap to first vertex to close • Double click to finish • Escape to + cancel + + )} {showPerfStatus && ( <> -
+ -
-
+ + -
+
)} diff --git a/app/packages/looker-3d/src/annotation/AnnotationPlane.tsx b/app/packages/looker-3d/src/annotation/AnnotationPlane.tsx new file mode 100644 index 00000000000..b529dc3516a --- /dev/null +++ b/app/packages/looker-3d/src/annotation/AnnotationPlane.tsx @@ -0,0 +1,347 @@ +import { Line, useCursor } from "@react-three/drei"; +import { useFrame } from "@react-three/fiber"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useRecoilState, useRecoilValue } from "recoil"; +import * as THREE from "three"; +import { useFo3dContext } from "../fo3d/context"; +import { Transformable } from "../labels/shared/TransformControls"; +import { + annotationPlaneAtom, + currentArchetypeSelectedForTransformAtom, + segmentPolylineStateAtom, + transformModeAtom, +} from "../state"; + +interface AnnotationPlaneProps { + showTransformControls?: boolean; + viewType?: "top" | "bottom" | "right" | "left" | "front" | "back"; + panelType?: "side" | "main"; +} + +export const AnnotationPlane = ({ + showTransformControls = true, + viewType = "top", + panelType = "main", +}: AnnotationPlaneProps) => { + const [annotationPlane, setAnnotationPlane] = + useRecoilState(annotationPlaneAtom); + + const isSegmenting = useRecoilValue(segmentPolylineStateAtom).isActive; + const transformMode = useRecoilValue(transformModeAtom); + + const [ + currentArchetypeSelectedForTransform, + setCurrentArchetypeSelectedForTransform, + ] = useRecoilState(currentArchetypeSelectedForTransformAtom); + + const isSelected = + currentArchetypeSelectedForTransform === "annotation-plane"; + + const { sceneBoundingBox, upVector } = useFo3dContext(); + const meshRef = useRef(null); + const lineRef = useRef(null); + const materialRef = useRef(null); + const transformControlsRef = useRef(null); + const [isHovered, setIsHovered] = useState(false); + const [isDragging, setIsDragging] = useState(false); + const [isMouseDown, setIsMouseDown] = useState(false); + const [dragStartPosition, setDragStartPosition] = useState<{ + x: number; + y: number; + } | null>(null); + + useEffect(() => { + setCurrentArchetypeSelectedForTransform(null); + setAnnotationPlane((prev) => ({ ...prev, enabled: false })); + }, [upVector]); + + const planeSize = useMemo(() => { + if (!sceneBoundingBox) return 10; + + const size = sceneBoundingBox.getSize(new THREE.Vector3()); + + if (upVector) { + // Find the two dimensions that are most orthogonal to the up vector + const upAbs = new THREE.Vector3( + Math.abs(upVector.x), + Math.abs(upVector.y), + Math.abs(upVector.z) + ); + + // Get the two largest orthogonal dimensions + const dimensions = [size.x, size.y, size.z]; + const orthogonalSizes = []; + + // Find dimensions where up vector component is smallest (most orthogonal) + for (let i = 0; i < 3; i++) { + if (upAbs.toArray()[i] < 0.5) { + // Less than 0.5 means mostly orthogonal + orthogonalSizes.push(Math.round(dimensions[i])); + } + } + + // 2 because we want to make the plane slightly larger than the scene + if (orthogonalSizes.length > 0) { + return Math.max(...orthogonalSizes, 10) * 2; + } + } + + return Math.max(size.x, size.y, size.z, 10) * 2; + }, [sceneBoundingBox, upVector]); + + const position = useMemo( + () => new THREE.Vector3(...annotationPlane.position), + [annotationPlane] + ); + const quaternion = useMemo( + () => new THREE.Quaternion(...annotationPlane.quaternion), + [annotationPlane] + ); + + // Determine which axes to show based on upVector and mode + const transformControlsProps = useMemo(() => { + // For rotate mode, always show all axes + if (transformMode === "rotate") { + return { showX: true, showY: true, showZ: true }; + } + + if (!upVector) { + return { showX: true, showY: true, showZ: true }; + } + + // Find which axis is most aligned with the up vector + const upAbs = new THREE.Vector3( + Math.abs(upVector.x), + Math.abs(upVector.y), + Math.abs(upVector.z) + ); + + const maxComponent = Math.max(upAbs.x, upAbs.y, upAbs.z); + + // Determine which axis is the up axis + const isXUp = Math.abs(upVector.x) === maxComponent; + const isYUp = Math.abs(upVector.y) === maxComponent; + const isZUp = Math.abs(upVector.z) === maxComponent; + + // For translate mode, only show the axis aligned with upVector + return { + showX: isXUp, + showY: isYUp, + showZ: isZUp, + }; + }, [upVector, transformMode]); + + // This effect syncs the showX, showY, and showZ values with the transformControlsProps values + useEffect(() => { + if (!annotationPlane.enabled) return; + + setAnnotationPlane((prev) => ({ + ...prev, + showX: transformControlsProps.showX, + showY: transformControlsProps.showY, + showZ: transformControlsProps.showZ, + })); + }, [transformControlsProps, annotationPlane.enabled, isSelected]); + + useCursor( + isHovered && isSelected && !isSegmenting, + "pointer", + isSegmenting ? "crosshair" : "auto" + ); + + // Simple pulsing animation for scale and opacity for visibility + useFrame((state) => { + if (materialRef.current && meshRef.current && panelType === "main") { + const time = state.clock.getElapsedTime(); + + const baseOpacity = isSelected ? 0.2 : 0.08; + const pulseOpacity = Math.sin(time * 2) * 0.05; + materialRef.current.opacity = baseOpacity + pulseOpacity; + + const baseScale = 1; + const pulseScale = Math.sin(time * 1.5) * 0.01; + meshRef.current.scale.setScalar(baseScale + pulseScale); + } + }); + + const handleMouseDown = useCallback((event: any) => { + setIsMouseDown(true); + setDragStartPosition({ x: event.clientX, y: event.clientY }); + }, []); + + const handleMouseMove = useCallback( + (event: any) => { + if (isMouseDown && dragStartPosition) { + const deltaX = Math.abs(event.clientX - dragStartPosition.x); + const deltaY = Math.abs(event.clientY - dragStartPosition.y); + const threshold = 5; // pixels + + if (deltaX > threshold || deltaY > threshold) { + setIsDragging(true); + } + } + }, + [isMouseDown, dragStartPosition] + ); + + const handleMouseUp = useCallback(() => { + setIsMouseDown(false); + setDragStartPosition(null); + // Reset dragging state after a short delay to allow click handlers to check it + setTimeout(() => setIsDragging(false), 0); + }, []); + + const handlePlaneClick = useCallback( + (event: any) => { + if (!showTransformControls || isSegmenting) return; + + event.stopPropagation(); + + if (!isDragging) { + setCurrentArchetypeSelectedForTransform((prev) => + prev === "annotation-plane" ? null : "annotation-plane" + ); + } + }, + [showTransformControls, isDragging, isSegmenting] + ); + + const handleTransformStart = useCallback(() => { + setIsDragging(true); + }, []); + + const syncAnnotationPlaneTransformation = useCallback(() => { + if (transformControlsRef.current && meshRef.current) { + const newPosition: [number, number, number] = [ + meshRef.current.position.x, + meshRef.current.position.y, + meshRef.current.position.z, + ]; + + const newQuaternion: [number, number, number, number] = [ + meshRef.current.quaternion.x, + meshRef.current.quaternion.y, + meshRef.current.quaternion.z, + meshRef.current.quaternion.w, + ]; + + setAnnotationPlane((prev) => ({ + ...prev, + position: newPosition, + quaternion: newQuaternion, + })); + } + }, []); + + const handleTransformEnd = useCallback(() => { + syncAnnotationPlaneTransformation(); + setIsDragging(false); + }, [syncAnnotationPlaneTransformation]); + + const handleTransformChange = useCallback(() => { + syncAnnotationPlaneTransformation(); + }, [syncAnnotationPlaneTransformation]); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape" && isSelected) { + setCurrentArchetypeSelectedForTransform(null); + setIsDragging(false); + event.stopImmediatePropagation(); + event.preventDefault(); + } + }; + + document.addEventListener("keydown", handleKeyDown); + + return () => { + document.removeEventListener("keydown", handleKeyDown); + }; + }, [isSelected]); + + if (!annotationPlane.enabled) { + return null; + } + + const shouldShowFullPlane = + panelType === "main" || + (panelType === "side" && (viewType === "top" || viewType === "bottom")); + + if (showTransformControls && shouldShowFullPlane) { + return ( + + setIsHovered(true)} + onPointerOut={() => setIsHovered(false)} + onPointerDown={handleMouseDown} + onPointerMove={handleMouseMove} + onPointerUp={handleMouseUp} + onClick={handlePlaneClick} + renderOrder={1000} + > + + + + + ); + } else { + const halfSize = planeSize / 2; + let points: [number, number, number][]; + + if (viewType === "left" || viewType === "right") { + points = [ + [0, -halfSize, 0], + [0, halfSize, 0], + ]; + } else { + points = [ + [-halfSize, 0, 0], + [halfSize, 0, 0], + ]; + } + + return ( + setIsHovered(true)} + onPointerOut={() => setIsHovered(false)} + onPointerDown={handleMouseDown} + onPointerMove={handleMouseMove} + onPointerUp={handleMouseUp} + onClick={handlePlaneClick} + > + + + ); + } +}; diff --git a/app/packages/looker-3d/src/annotation/Crosshair3D.tsx b/app/packages/looker-3d/src/annotation/Crosshair3D.tsx new file mode 100644 index 00000000000..303c56bc86e --- /dev/null +++ b/app/packages/looker-3d/src/annotation/Crosshair3D.tsx @@ -0,0 +1,123 @@ +import { useTheme } from "@fiftyone/components"; +import { Html } from "@react-three/drei"; +import { useThree } from "@react-three/fiber"; +import { useMemo } from "react"; +import { useRecoilState, useRecoilValue } from "recoil"; +import styled from "styled-components"; +import * as THREE from "three"; +import { useFo3dContext } from "../fo3d/context"; +import { + isCurrentlyTransformingAtom, + sharedCursorPositionAtom, +} from "../state"; + +const CROSS_HAIR_SIZE = 20; +const LINE_WIDTH = 2; +const OPACITY = 0.7; + +const HtmlContainer = styled.div` + position: relative; + pointer-events: none; + z-index: 1000; +`; + +const CrosshairBox = styled.div` + position: absolute; + left: -${CROSS_HAIR_SIZE / 2}px; + top: -${CROSS_HAIR_SIZE / 2}px; + width: ${CROSS_HAIR_SIZE}px; + height: ${CROSS_HAIR_SIZE}px; + pointer-events: none; +`; + +const Horizontal = styled.div<{ $color: string }>` + position: absolute; + left: 0; + top: 50%; + width: 100%; + height: ${LINE_WIDTH}px; + background-color: ${(p) => p.$color}; + opacity: ${OPACITY}; + transform: translateY(-50%); +`; + +const Vertical = styled.div<{ $color: string }>` + position: absolute; + left: 50%; + top: 0; + width: ${LINE_WIDTH}px; + height: 100%; + background-color: ${(p) => p.$color}; + opacity: ${OPACITY}; + transform: translateX(-50%); +`; + +export const Crosshair3D = () => { + const { camera } = useThree(); + const { sceneBoundingBox } = useFo3dContext(); + const theme = useTheme(); + const isCurrentlyTransforming = useRecoilValue(isCurrentlyTransformingAtom); + + const worldPosition = useRecoilValue(sharedCursorPositionAtom); + + const worldVector = useMemo(() => { + if (!worldPosition) return null; + + const vector = new THREE.Vector3(...worldPosition); + + if (sceneBoundingBox) { + vector.clamp(sceneBoundingBox.min, sceneBoundingBox.max); + } + + return vector; + }, [worldPosition, sceneBoundingBox]); + + // Convert 3D world position to 2D screen coordinates + const screenPosition = useMemo(() => { + if (!worldVector) return null; + + return worldVector.clone().project(camera); + }, [worldVector, camera]); + + // Calculate scale based on camera zoom (inverse) + const scale = useMemo(() => { + if (camera instanceof THREE.OrthographicCamera) { + return 1 / camera.zoom; + } + return 1; + }, [camera]); + + if (isCurrentlyTransforming) { + return null; + } + + if (!screenPosition) { + return null; + } + + // Only render if the point is in front of the camera + if (screenPosition.z > 1) { + return null; + } + + // Only render if the point is within the viewport bounds (NDC: -1 to 1) + if ( + screenPosition.x < -1 || + screenPosition.x > 1 || + screenPosition.y < -1 || + screenPosition.y > 1 + ) { + return null; + } + + return ( + + + + + + + + + ); +}; diff --git a/app/packages/looker-3d/src/annotation/MultiPanelView.tsx b/app/packages/looker-3d/src/annotation/MultiPanelView.tsx new file mode 100644 index 00000000000..c8f384777ed --- /dev/null +++ b/app/packages/looker-3d/src/annotation/MultiPanelView.tsx @@ -0,0 +1,485 @@ +import { useTheme } from "@fiftyone/components"; +import { useBrowserStorage } from "@fiftyone/state"; +import { MenuItem, Select } from "@mui/material"; +import { + Bounds, + CameraControls, + MapControls, + OrthographicCamera, + View, +} from "@react-three/drei"; +import { Canvas } from "@react-three/fiber"; +import CameraControlsImpl from "camera-controls"; +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; +import { useRecoilCallback } from "recoil"; +import styled from "styled-components"; +import * as THREE from "three"; +import { Box3, Vector3 } from "three"; +import { PcdColorMapTunnel } from "../components/PcdColormapModal"; +import { StatusBarRootContainer } from "../containers"; +import { useFo3dContext } from "../fo3d/context"; +import { Fo3dSceneContent } from "../fo3d/Fo3dCanvas"; +import { FoSceneComponent } from "../fo3d/FoScene"; +import { Gizmos } from "../fo3d/Gizmos"; +import HoverMetadataHUD from "../fo3d/HoverMetadataHUD"; +import { Lights } from "../fo3d/scene-controls/lights/Lights"; +import { FoScene } from "../hooks"; +import { ThreeDLabels } from "../labels"; +import { + activeNodeAtom, + currentArchetypeSelectedForTransformAtom, + currentHoveredPointAtom, + selectedPolylineVertexAtom, +} from "../state"; +import { StatusBar } from "../StatusBar"; +import { AnnotationPlane } from "./AnnotationPlane"; +import { Crosshair3D } from "./Crosshair3D"; +import { SegmentPolylineRenderer } from "./SegmentPolylineRenderer"; + +const CANVAS_WRAPPER_ID = "sample3d-canvas-wrapper"; + +const GridMain = styled.main` + display: grid; + grid-template-areas: "main top" "main bottom"; + grid-template-columns: 2fr 1.1fr; + grid-template-rows: 1fr 1fr; + height: 100%; + width: 100%; + gap: 1px; +`; + +const AbsoluteFill = styled.div` + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; +`; + +const MainPanelContainer = styled.div` + grid-area: main; + position: relative; + z-index: 2; +`; + +const SidePanelContainer = styled.div<{ $area: string }>` + grid-area: ${(p) => p.$area}; + position: relative; + z-index: 200; +`; + +const ViewSelectorWrapper = styled.div` + position: absolute; + top: 10px; + left: 10px; + z-index: 1000; +`; + +/** + * Calculate camera position for different side panel views based on upVector and lookAt point + */ +const calculateCameraPositionForSidePanel = ( + viewType: ViewType, + upVector: Vector3, + lookAt: Vector3, + sceneBoundingBox: Box3 | null +): Vector3 => { + if (!sceneBoundingBox) { + // Fallback to default positions if no bounding box + const defaultPositions = { + Top: [0, 10, 0] as [number, number, number], + Bottom: [0, -10, 0] as [number, number, number], + Left: [-10, 0, 0] as [number, number, number], + Right: [10, 0, 0] as [number, number, number], + Back: [0, 0, -10] as [number, number, number], + Front: [0, 0, 10] as [number, number, number], + }; + return new Vector3(...defaultPositions[viewType]); + } + + const size = new Vector3(); + sceneBoundingBox.getSize(size); + const maxSize = Math.max(size.x, size.y, size.z); + const distance = maxSize * 2.5; + + const upDir = upVector.clone().normalize(); + const center = lookAt.clone(); + + // Create orthogonal vectors for different views + let direction: Vector3; + + switch (viewType) { + case "Top": + direction = upDir.clone(); + break; + case "Bottom": + direction = upDir.clone().negate(); + break; + case "Left": + // Create a vector perpendicular to up vector + if (Math.abs(upDir.y) > 0.9) { + // If up is mostly Y, use negative X axis for left + direction = new Vector3(-1, 0, 0); + } else { + const right = new Vector3(0, 1, 0).cross(upDir).normalize(); + direction = right.negate(); + } + break; + case "Right": + // Opposite of Left + if (Math.abs(upDir.y) > 0.9) { + direction = new Vector3(1, 0, 0); + } else { + direction = new Vector3(0, 1, 0).cross(upDir).normalize(); + } + break; + case "Front": + // Create a vector perpendicular to both up and left + if (Math.abs(upDir.y) > 0.9) { + direction = new Vector3(0, 0, 1); + } else { + const left = new Vector3(0, -1, 0).cross(upDir).normalize(); + direction = upDir.clone().cross(left).normalize(); + } + break; + case "Back": + // Opposite of Front + if (Math.abs(upDir.y) > 0.9) { + direction = new Vector3(0, 0, -1); + } else { + const left = new Vector3(0, 1, 0).cross(upDir).normalize(); + direction = upDir.clone().cross(left).normalize(); + } + break; + default: + direction = upDir.clone(); + } + + return center.clone().add(direction.multiplyScalar(distance)); +}; + +type ViewType = "Top" | "Bottom" | "Left" | "Right" | "Front" | "Back"; + +interface MultiPanelViewState { + top: ViewType; + bottom: ViewType; +} + +const defaultState: MultiPanelViewState = { + top: "Front", + bottom: "Right", +}; + +interface MultiPanelViewProps { + assetsGroupRef: React.RefObject; + cameraControlsRef: React.MutableRefObject; + cameraRef: React.MutableRefObject; + defaultCameraPosition: Vector3; + foScene: FoScene; + sample: any; +} + +export const MultiPanelView = ({ + assetsGroupRef, + cameraControlsRef, + cameraRef, + defaultCameraPosition, + foScene, + sample, +}: MultiPanelViewProps) => { + const { isSceneInitialized, upVector, lookAt, sceneBoundingBox } = + useFo3dContext(); + const [panelState, setPanelState] = useBrowserStorage( + "fo3d-multi-panel-state", + defaultState + ); + + const containerRef = useRef(null); + + const resetActiveNode = useRecoilCallback( + ({ set }) => + (event: MouseEvent | null) => { + if (event?.type === "contextmenu") return; + set(activeNodeAtom, null); + set(currentHoveredPointAtom, null); + set(selectedPolylineVertexAtom, null); + set(currentArchetypeSelectedForTransformAtom, null); + }, + [] + ); + + useLayoutEffect(() => { + const canvas = document.getElementById(CANVAS_WRAPPER_ID); + if (canvas) { + canvas.querySelector("canvas")?.setAttribute("canvas-loaded", "true"); + } + }, [isSceneInitialized]); + + const [autoRotate] = useBrowserStorage("fo3dAutoRotate", false); + + const [pointCloudSettings] = useBrowserStorage("fo3dPointCloudSettings", { + enableTooltip: false, + rayCastingSensitivity: "medium", + }); + + const setPanelView = useCallback( + ( + which: keyof Pick, + view: ViewType + ) => { + setPanelState((prev) => ({ ...prev, [which]: view })); + }, + [setPanelState] + ); + + return ( + + + + + + + + + + + setPanelView("top", view)} + foScene={foScene} + upVector={upVector} + lookAt={lookAt} + sceneBoundingBox={sceneBoundingBox} + isSceneInitialized={isSceneInitialized} + sample={sample} + /> + + setPanelView("bottom", view)} + foScene={foScene} + upVector={upVector} + lookAt={lookAt} + sceneBoundingBox={sceneBoundingBox} + isSceneInitialized={isSceneInitialized} + sample={sample} + /> + + + + + + ); +}; + +const MainPanel = ({ + cameraRef, + cameraControlsRef, + defaultCameraPosition, + autoRotate, + foScene, + upVector, + isSceneInitialized, + sample, + pointCloudSettings, + assetsGroupRef, +}: { + cameraRef: React.RefObject; + cameraControlsRef: React.RefObject; + defaultCameraPosition: THREE.Vector3; + autoRotate: boolean; + foScene: any; + upVector: Vector3 | null; + isSceneInitialized: boolean; + sample: any; + pointCloudSettings: any; + assetsGroupRef: React.RefObject; +}) => { + return ( + + + + + + ); +}; + +const SidePanel = ({ + which, + view, + setView, + foScene, + upVector, + lookAt, + sceneBoundingBox, + isSceneInitialized, + sample, +}: { + which: "top" | "middle" | "bottom"; + view: ViewType; + setView: (view: ViewType) => void; + foScene: FoScene; + upVector: Vector3 | null; + lookAt: Vector3 | null; + sceneBoundingBox: Box3 | null; + isSceneInitialized: boolean; + sample: any; +}) => { + const position = useMemo( + () => + upVector && lookAt + ? calculateCameraPositionForSidePanel( + view, + upVector, + lookAt, + sceneBoundingBox + ) + : new Vector3(0, 10, 0), + [view, upVector, lookAt, sceneBoundingBox] + ); + + const theme = useTheme(); + + const cameraRef = useRef(); + + // We need to observe the bounds for a short period of time to ensure the camera is in the correct position + // But turn it off so that users can pan/zoom and work with a stable scene + const [observe, setObserve] = useState(true); + + useEffect(() => { + const timer = setTimeout(() => setObserve(false), 1000); + return () => clearTimeout(timer); + }, []); + + return ( + + + + + + + + + + {isSceneInitialized && ( + + )} + + + + + + + + + + + ); +}; diff --git a/app/packages/looker-3d/src/annotation/PolylinePointMarker.tsx b/app/packages/looker-3d/src/annotation/PolylinePointMarker.tsx new file mode 100644 index 00000000000..752a50ac7d7 --- /dev/null +++ b/app/packages/looker-3d/src/annotation/PolylinePointMarker.tsx @@ -0,0 +1,260 @@ +import { useCursor } from "@react-three/drei"; +import { useFrame } from "@react-three/fiber"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { useRecoilState, useSetRecoilState } from "recoil"; +import { Mesh, Vector3, Matrix4 } from "three"; +import { LABEL_3D_ANNOTATION_POINT_SELECTED_FOR_TRANSFORMATION_COLOR } from "../constants"; +import { Transformable } from "../labels/shared/TransformControls"; +import { + currentArchetypeSelectedForTransformAtom, + editSegmentsModeAtom, + hoveredPolylineInfoAtom, + segmentPolylineStateAtom, + selectedPolylineVertexAtom, + tempVertexTransformsAtom, + transformModeAtom, +} from "../state"; +import type { SelectedPoint } from "./types"; + +interface PolylinePointMarkerProps { + position: Vector3; + color?: string; + size?: number; + pulsate?: boolean; + isDraggable?: boolean; + labelId: string; + segmentIndex: number; + pointIndex: number; + onPointMove?: (newPosition: Vector3) => void; +} + +export const PolylinePointMarker = ({ + position, + color = "#ffffff", + size = 0.05, + pulsate = true, + isDraggable = false, + labelId, + segmentIndex, + pointIndex, + onPointMove, +}: PolylinePointMarkerProps) => { + const meshRef = useRef(null); + const transformControlsRef = useRef(null); + const [startMatrix, setStartMatrix] = useState(null); + + const [isHovered, setIsHovered] = useState(false); + + const setHoveredPolylineInfo = useSetRecoilState(hoveredPolylineInfoAtom); + const setTransformMode = useSetRecoilState(transformModeAtom); + + const [selectedPoint, setSelectedPoint] = useRecoilState( + selectedPolylineVertexAtom + ); + const setCurrentArchetypeSelectedForTransform = useSetRecoilState( + currentArchetypeSelectedForTransformAtom + ); + + const setSegmentPolylineState = useSetRecoilState(segmentPolylineStateAtom); + const setEditSegmentsMode = useSetRecoilState(editSegmentsModeAtom); + + const isSelected = + selectedPoint?.labelId === labelId && + selectedPoint?.segmentIndex === segmentIndex && + selectedPoint?.pointIndex === pointIndex; + + useCursor(isHovered && isDraggable, "grab", "auto"); + + const handlePointClick = useCallback( + (event: any) => { + if (!isDraggable) return; + + event.stopPropagation(); + + const newSelectedPoint: SelectedPoint = { + labelId, + segmentIndex, + pointIndex, + }; + + setSelectedPoint(newSelectedPoint); + setCurrentArchetypeSelectedForTransform("point"); + setTransformMode("translate"); + + // Deactivate other modes when selecting a point + setSegmentPolylineState((prev) => ({ + ...prev, + isActive: false, + })); + setEditSegmentsMode(false); + }, + [ + isDraggable, + labelId, + segmentIndex, + pointIndex, + setSelectedPoint, + setCurrentArchetypeSelectedForTransform, + setTransformMode, + setSegmentPolylineState, + setEditSegmentsMode, + ] + ); + + const syncPointTransformationToTempStore = useCallback(() => { + if (groupRef.current) { + const worldPosition = groupRef.current.position.clone(); + setTempVertexTransforms({ + position: [worldPosition.x, worldPosition.y, worldPosition.z], + quaternion: groupRef.current.quaternion.toArray(), + }); + } + }, []); + + const handleTransformStart = useCallback(() => { + if (groupRef.current) { + // Store the start matrix for computing delta later + setStartMatrix(groupRef.current.matrixWorld.clone()); + } + }, []); + + const handleTransformEnd = useCallback(() => { + setTempVertexTransforms(null); + + if (groupRef.current && onPointMove && startMatrix) { + // Compute world-space delta from start and end matrices + const endMatrix = groupRef.current.matrixWorld.clone(); + const deltaMatrix = endMatrix + .clone() + .multiply(startMatrix.clone().invert()); + + // Extract position delta from the delta matrix + const deltaPosition = new Vector3(); + deltaPosition.setFromMatrixPosition(deltaMatrix); + + const newPosition = position.clone().add(deltaPosition); + + groupRef.current.position.set(0, 0, 0); + + onPointMove(newPosition); + + // note: this is a hack: + // transformControls is updating in a buggy way when the point is moved + const prevSelectedPoint = selectedPoint; + setSelectedPoint(null); + setTimeout(() => { + setSelectedPoint(prevSelectedPoint); + }, 0); + + // Clear the start matrix + setStartMatrix(null); + } + }, [onPointMove, selectedPoint, position, startMatrix]); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape" && selectedPoint) { + setSelectedPoint(null); + event.stopImmediatePropagation(); + event.preventDefault(); + } + }; + + document.addEventListener("keydown", handleKeyDown); + + return () => { + document.removeEventListener("keydown", handleKeyDown); + }; + }, [setSelectedPoint, selectedPoint]); + + // Apply distance-based scaling with min/max bounds + useFrame(({ clock, camera }) => { + const distance = camera.position.distanceTo(position); + + const rawScale = distance; + const minScale = 2; + const maxScale = 10.0; + const screenSpaceScale = Math.max(minScale, Math.min(maxScale, rawScale)); + let finalScale = screenSpaceScale; + + if (pulsate) { + const t = clock.getElapsedTime(); + const pulse = 0.8 + 0.2 * Math.sin(t * 3); + finalScale = screenSpaceScale * pulse; + } + + if (meshRef.current) { + meshRef.current.scale.set(finalScale, finalScale, finalScale); + } + + if (transformControlsRef.current) { + transformControlsRef.current.position.set(0, 0, 0); + groupRef.current.updateMatrixWorld(); + transformControlsRef.current.updateMatrixWorld(); + } + }); + + const groupRef = useRef(null); + + const [tempVertexTransforms, setTempVertexTransforms] = useRecoilState( + tempVertexTransformsAtom(`${labelId}-${segmentIndex}-${pointIndex}`) + ); + + useEffect(() => { + return () => { + setTempVertexTransforms(null); + }; + }, []); + + return ( + + + { + setIsHovered(true); + setHoveredPolylineInfo({ + labelId, + segmentIndex, + pointIndex, + }); + }} + onPointerOut={() => { + setIsHovered(false); + setHoveredPolylineInfo(null); + }} + onClick={handlePointClick} + > + + + + + + ); +}; diff --git a/app/packages/looker-3d/src/annotation/SegmentPolylineRenderer.tsx b/app/packages/looker-3d/src/annotation/SegmentPolylineRenderer.tsx new file mode 100644 index 00000000000..38eb9934d6b --- /dev/null +++ b/app/packages/looker-3d/src/annotation/SegmentPolylineRenderer.tsx @@ -0,0 +1,573 @@ +import { objectId } from "@fiftyone/utilities"; +import { Line as LineDrei } from "@react-three/drei"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useRecoilState, useRecoilValue, useSetRecoilState } from "recoil"; +import * as THREE from "three"; +import { SCENE_BOUNDS_EXPANSION_FACTOR, SNAP_TOLERANCE } from "../constants"; +import { useFo3dContext } from "../fo3d/context"; +import { useEmptyCanvasInteraction } from "../hooks/use-empty-canvas-interaction"; +import { + annotationPlaneAtom, + isSegmentingPointerDownAtom, + isSnapToAnnotationPlaneAtom, + polylineEffectivePointsAtom, + polylinePointTransformsAtom, + segmentPolylineStateAtom, + selectedLabelForAnnotationAtom, + sharedCursorPositionAtom, + snapCloseAutomaticallyAtom, + tempPolylinesAtom, +} from "../state"; +import { expandBoundingBox, getPlaneFromPositionAndQuaternion } from "../utils"; +import { PolylinePointMarker } from "./PolylinePointMarker"; +import type { PolylinePointTransform, TempPolyline } from "./types"; +import { shouldClosePolylineLoop } from "./utils/polyline-utils"; + +interface SegmentPolylineRendererProps { + ignoreEffects?: boolean; + color?: string; + lineWidth?: number; + rubberBandColor?: string; + rubberBandLineWidth?: number; +} + +export const SegmentPolylineRenderer = ({ + ignoreEffects = false, + color = "#00ff00", + lineWidth = 3, + rubberBandColor = "#ff0000", + rubberBandLineWidth = 2, +}: SegmentPolylineRendererProps) => { + const [segmentState, setSegmentState] = useRecoilState( + segmentPolylineStateAtom + ); + const [tempPolylines, setTempPolylines] = useRecoilState(tempPolylinesAtom); + const setPolylinePointTransforms = useSetRecoilState( + polylinePointTransformsAtom + ); + const selectedLabelForAnnotation = useRecoilValue( + selectedLabelForAnnotationAtom + ); + + const [tempLabelId, setTempLabelId] = useState(objectId()); + + useEffect(() => { + if (ignoreEffects) return; + + const newObjectId = objectId(); + setTempLabelId(newObjectId); + }, [segmentState.isActive]); + + const polylineEffectivePoints = useRecoilValue( + polylineEffectivePointsAtom(selectedLabelForAnnotation?._id || tempLabelId) + ); + const setIsActivelySegmenting = useSetRecoilState( + isSegmentingPointerDownAtom + ); + const setSharedCursorPosition = useSetRecoilState(sharedCursorPositionAtom); + const annotationPlane = useRecoilValue(annotationPlaneAtom); + const isSnapToAnnotationPlane = useRecoilValue(isSnapToAnnotationPlaneAtom); + const snapCloseAutomatically = useRecoilValue(snapCloseAutomaticallyAtom); + const { upVector, sceneBoundingBox } = useFo3dContext(); + + // We want annotation margin to be larger than the scene bounds + const expandedBoundingBox = useMemo(() => { + if (!sceneBoundingBox || sceneBoundingBox.isEmpty()) { + return null; + } + return expandBoundingBox(sceneBoundingBox, SCENE_BOUNDS_EXPANSION_FACTOR); + }, [sceneBoundingBox]); + + // Track last click time for double-click detection + const lastClickTimeRef = useRef(0); + const DOUBLE_CLICK_THRESHOLD_MS = 200; + const lastAddedVertexRef = useRef<[number, number, number] | null>(null); + + // Check if current position is close to first vertex for closing + const shouldCloseLoop = useCallback( + (currentPos: THREE.Vector3): boolean => { + return shouldClosePolylineLoop( + segmentState.vertices, + [currentPos.x, currentPos.y, currentPos.z], + SNAP_TOLERANCE + ); + }, + [segmentState.vertices] + ); + + const handleClick = useCallback( + (worldPos: THREE.Vector3) => { + setIsActivelySegmenting(false); + + if (!segmentState.isActive) return; + + const finalPos = worldPos; + + const currentTime = Date.now(); + const isDoubleClick = + currentTime - lastClickTimeRef.current < DOUBLE_CLICK_THRESHOLD_MS; + + lastClickTimeRef.current = currentTime; + + // Check for double-click behavior + if (isDoubleClick && lastAddedVertexRef.current) { + // Remove the last added vertex from the previous click + setSegmentState((prev) => ({ + ...prev, + vertices: prev.vertices.slice(0, -1), + })); + + // Get the vertices without the duplicate + const verticesWithoutDuplicate = segmentState.vertices.slice(0, -1); + + if (snapCloseAutomatically) { + // Close the polyline automatically + const tempPolyline: TempPolyline = { + id: `temp-polyline-${Date.now()}`, + vertices: verticesWithoutDuplicate, + isClosed: true, + color, + lineWidth, + }; + + setTempPolylines((prev) => [...prev, tempPolyline]); + + setSegmentState({ + isActive: true, + vertices: [], + currentMousePosition: null, + isClosed: false, + }); + } else { + // End the segment at the current position without closing + const tempPolyline: TempPolyline = { + id: `temp-polyline-${Date.now()}`, + vertices: [ + ...verticesWithoutDuplicate, + [finalPos.x, finalPos.y, finalPos.z], + ], + isClosed: false, + color, + lineWidth, + }; + + setTempPolylines((prev) => [...prev, tempPolyline]); + + setSegmentState({ + isActive: true, + vertices: [], + currentMousePosition: null, + isClosed: false, + }); + } + + lastAddedVertexRef.current = null; + return; + } + + // Check if we should close the loop by clicking near the first vertex + if (shouldCloseLoop(finalPos)) { + const tempPolyline: TempPolyline = { + id: `temp-polyline-${Date.now()}`, + vertices: segmentState.vertices, + isClosed: true, + color, + lineWidth, + }; + + setTempPolylines((prev) => [...prev, tempPolyline]); + + setSegmentState({ + isActive: true, + vertices: [], + currentMousePosition: null, + isClosed: false, + }); + + lastAddedVertexRef.current = null; + return; + } + + // Add new vertex for single click + const newVertex: [number, number, number] = [ + finalPos.x, + finalPos.y, + finalPos.z, + ]; + setSegmentState((prev) => ({ + ...prev, + vertices: [...prev.vertices, newVertex], + })); + + lastAddedVertexRef.current = newVertex; + }, + [ + segmentState, + shouldCloseLoop, + setSegmentState, + setTempPolylines, + snapCloseAutomatically, + ] + ); + + // Handle mouse move for rubber band effect + const handleMouseMove = useCallback( + (worldPos: THREE.Vector3) => { + const finalPos = worldPos; + + // Constrain position to expanded scene bounds + if (expandedBoundingBox) { + finalPos.clamp(expandedBoundingBox.min, expandedBoundingBox.max); + } + + setSegmentState((prev) => ({ + ...prev, + currentMousePosition: [finalPos.x, finalPos.y, finalPos.z], + })); + + setSharedCursorPosition([finalPos.x, finalPos.y, finalPos.z]); + }, + [expandedBoundingBox] + ); + + // Calculate the annotation plane for raycasting + const raycastPlane = useMemo(() => { + if (annotationPlane.enabled || isSnapToAnnotationPlane) { + const plane = getPlaneFromPositionAndQuaternion( + annotationPlane.position, + annotationPlane.quaternion + ); + // Note: createPlane negates the constant, so we negate it here to cancel that out + return { + normal: plane.normal, + constant: -plane.constant, + }; + } + return { + normal: upVector || new THREE.Vector3(0, 0, 1), + constant: 0, + }; + }, [annotationPlane, isSnapToAnnotationPlane, upVector]); + + useEmptyCanvasInteraction({ + onPointerUp: segmentState.isActive ? handleClick : undefined, + onPointerDown: segmentState.isActive + ? () => setIsActivelySegmenting(true) + : undefined, + onPointerMove: handleMouseMove, + planeNormal: raycastPlane.normal, + planeConstant: raycastPlane.constant, + }); + + useEffect(() => { + if (ignoreEffects) return; + if (segmentState.isActive) { + document.body.style.cursor = "crosshair"; + return () => { + document.body.style.cursor = "default"; + }; + } + }, [segmentState.isActive]); + + useEffect(() => { + if (ignoreEffects) return; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape" && segmentState.isActive) { + setSegmentState({ + isActive: false, + vertices: [], + currentMousePosition: null, + isClosed: false, + }); + + setIsActivelySegmenting(false); + + event.stopImmediatePropagation(); + event.preventDefault(); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [segmentState.isActive, setSegmentState]); + + // Render completed segments + const completedSegments = useMemo(() => { + if (segmentState.vertices.length < 2) return null; + + const segments = []; + for (let i = 0; i < segmentState.vertices.length - 1; i++) { + segments.push( + + ); + } + + // If closed, add line from last vertex to first + if (segmentState.isClosed && segmentState.vertices.length > 2) { + segments.push( + + ); + } + + return segments; + }, [segmentState.vertices, segmentState.isClosed, color, lineWidth]); + + // Temporary polylines + const tempPolylineElements = useMemo(() => { + return tempPolylines.map((polyline) => { + const segments = []; + + // Line segments between consecutive vertices + for (let i = 0; i < polyline.vertices.length - 1; i++) { + segments.push( + + ); + } + + // If closed, add line from last vertex to first + if (polyline.isClosed && polyline.vertices.length > 2) { + segments.push( + + ); + } + + return segments; + }); + }, [tempPolylines]); + + // Rubber band from last vertex to current mouse position + const rubberBand = useMemo(() => { + if ( + !segmentState.isActive || + segmentState.vertices.length === 0 || + !segmentState.currentMousePosition + ) { + return null; + } + + const lastVertex = segmentState.vertices[segmentState.vertices.length - 1]; + + return ( + + ); + }, [ + segmentState.isActive, + segmentState.vertices, + segmentState.currentMousePosition, + rubberBandColor, + rubberBandLineWidth, + ]); + + const vertexMarkers = useMemo(() => { + const markers = []; + + // Current segment vertices (while actively segmenting) + if (segmentState.vertices.length > 0) { + segmentState.vertices.forEach((vertex, index) => { + markers.push( + + ); + }); + } + + // Completed polylines vertices (for editing) + tempPolylines.forEach((polyline, polylineIndex) => { + polyline.vertices.forEach((vertex, vertexIndex) => { + markers.push( + { + // Update the vertex position in tempPolylines + setTempPolylines((prev) => + prev.map((p) => + p.id === polyline.id + ? { + ...p, + vertices: p.vertices.map((v, i) => + i === vertexIndex + ? ([ + newPosition.x, + newPosition.y, + newPosition.z, + ] as [number, number, number]) + : v + ), + } + : p + ) + ); + }} + /> + ); + }); + }); + + return markers.length > 0 ? markers : null; + }, [segmentState.vertices, tempPolylines, color, setTempPolylines]); + + // Sync with polylinePointTransformsAtom + useEffect(() => { + if (ignoreEffects) return; + + if (tempPolylines.length === 0) return; + const labelId = selectedLabelForAnnotation?._id || tempLabelId; + + const newPolyline: PolylinePointTransform[] = []; + + // Find the highest existing segment index so that we can start from the next one + const highestSegmentIndex = + polylineEffectivePoints.length > 0 + ? polylineEffectivePoints.length - 1 + : -1; + + const segmentIndexOffset = highestSegmentIndex + 1; + + // Each vertex gets a segmentIndex (which segment it belongs to) and pointIndex (0 or 1 for start/end of segment) + for (let i = 0; i < tempPolylines[0].vertices.length; i++) { + const vertex = tempPolylines[0].vertices[i]; + + // For each vertex, it can be either the start or end of a segment + // If it's the last vertex and the polyline is closed, it connects back to the first vertex + // Otherwise, it connects to the next vertex + if (i < tempPolylines[0].vertices.length - 1) { + // This vertex is the start of a segment that goes to the next vertex + newPolyline.push({ + segmentIndex: segmentIndexOffset + i, + pointIndex: 0, + position: vertex, + }); + // The next vertex is the end of this segment + newPolyline.push({ + segmentIndex: segmentIndexOffset + i, + pointIndex: 1, + position: tempPolylines[0].vertices[i + 1], + }); + } else if ( + tempPolylines[0].isClosed && + tempPolylines[0].vertices.length > 2 + ) { + // Last vertex connects back to first vertex if closed + newPolyline.push({ + segmentIndex: segmentIndexOffset + i, + pointIndex: 0, + position: vertex, + }); + newPolyline.push({ + segmentIndex: segmentIndexOffset + i, + pointIndex: 1, + position: tempPolylines[0].vertices[0], + }); + } + } + + setPolylinePointTransforms((prev) => { + const newTransforms = [...(prev[labelId] || []), ...newPolyline]; + + // Remove duplicates + const uniqueTransforms = newTransforms.filter( + (transform, index, self) => + index === + self.findIndex( + (t) => + t.segmentIndex === transform.segmentIndex && + t.pointIndex === transform.pointIndex + ) + ); + + return { + ...prev, + [labelId]: uniqueTransforms, + }; + }); + + // Get out of segmenting mode + setSegmentState({ + isActive: false, + vertices: [], + currentMousePosition: null, + isClosed: false, + }); + + setIsActivelySegmenting(false); + + setTempPolylines([]); + }, [tempPolylines, polylineEffectivePoints, tempLabelId]); + + if ( + !segmentState.isActive && + segmentState.vertices.length === 0 && + tempPolylines.length === 0 + ) { + return null; + } + + return ( + + {tempPolylineElements} + {completedSegments} + {rubberBand} + {vertexMarkers} + + ); +}; diff --git a/app/packages/looker-3d/src/annotation/annotation-toolbar/AnnotationToolbar.tsx b/app/packages/looker-3d/src/annotation/annotation-toolbar/AnnotationToolbar.tsx new file mode 100644 index 00000000000..969186af83f --- /dev/null +++ b/app/packages/looker-3d/src/annotation/annotation-toolbar/AnnotationToolbar.tsx @@ -0,0 +1,254 @@ +import { DragIndicator } from "@mui/icons-material"; +import { Box, IconButton, Tooltip, Typography, styled } from "@mui/material"; +import React, { useCallback, useRef, useState } from "react"; +import { useRecoilState } from "recoil"; +import { annotationToolbarPositionAtom } from "../../state"; +import type { AnnotationToolbarProps } from "../types"; +import { useAnnotationActions } from "./useAnnotationActions"; + +const ToolbarContainer = styled(Box)<{ + topposition: number; + isdragging?: string; +}>(({ theme, topposition, isdragging }) => { + const isDraggingBool = isdragging === "true"; + + return { + position: "absolute", + top: `${topposition}%`, + left: "8px", + transform: "translateY(-50%)", + display: "flex", + flexDirection: "column", + backgroundColor: theme.palette.background.paper, + borderRadius: "6px", + boxShadow: isDraggingBool + ? "0 4px 16px rgba(0, 0, 0, 0.2)" + : "0 2px 8px rgba(0, 0, 0, 0.12)", + border: `1px solid ${theme.palette.divider}`, + zIndex: 1000, + minWidth: "36px", + opacity: isDraggingBool ? 0.95 : 0.75, + userSelect: "none", + transition: isDraggingBool + ? "none" + : "opacity 0.2s ease, box-shadow 0.2s ease", + + "&:hover": { + opacity: 0.95, + }, + }; +}); + +const DraggableHeader = styled(Box)<{ isdragging?: string }>( + ({ theme, isdragging }) => { + const isDraggingBool = isdragging === "true"; + + return { + width: "100%", + height: "12px", + borderRadius: "6px 6px 0 0", + cursor: isDraggingBool ? "grabbing" : "grab", + opacity: 0, + transition: "opacity 0.2s ease", + margin: "2px 2px 0 2px", + position: "relative", + display: "flex", + alignItems: "center", + justifyContent: "center", + + ".MuiBox-root:hover &": { + opacity: 0.8, + }, + + // rotate drag handler by 90 degrees + "& > *": { + transform: "rotate(90deg)", + }, + }; + } +); + +const ToolbarContent = styled(Box)({ + display: "flex", + flexDirection: "column", + gap: "8px", + padding: "8px", +}); + +const ActionGroup = styled(Box)({ + display: "flex", + flexDirection: "column", + gap: "6px", + alignItems: "center", +}); + +const GroupLabel = styled(Typography)(({ theme }) => ({ + fontSize: "9px", + fontWeight: 600, + color: theme.palette.text.secondary, + textAlign: "center", + marginBottom: "2px", + textTransform: "uppercase", + letterSpacing: "0.3px", +})); + +const ActionButton = styled(IconButton)<{ + isactive?: string; + isdisabled?: string; +}>(({ theme, isactive, isdisabled }) => { + const isActiveBool = isactive === "true"; + const isDisabledBool = isdisabled === "true"; + + return { + width: "28px", + height: "28px", + cursor: isDisabledBool ? "not-allowed" : "pointer", + backgroundColor: isActiveBool ? theme.palette.primary.main : "transparent", + color: isActiveBool + ? theme.palette.primary.contrastText + : isDisabledBool + ? theme.palette.text.disabled + : theme.palette.text.primary, + display: "flex", + alignItems: "center", + justifyContent: "center", + "&:hover": { + backgroundColor: isActiveBool + ? theme.palette.primary.dark + : theme.palette.action.hover, + }, + "&:disabled": { + color: theme.palette.text.disabled, + backgroundColor: "transparent", + }, + "& .MuiSvgIcon-root": { + fontSize: "18px", + }, + }; +}); + +export const AnnotationToolbar = ({ className }: AnnotationToolbarProps) => { + const { actions } = useAnnotationActions(); + const [position, setPosition] = useRecoilState(annotationToolbarPositionAtom); + const [isDragging, setIsDragging] = useState(false); + const [dragStart, setDragStart] = useState({ y: 0, position: 0 }); + const containerRef = useRef(null); + + const handleActionClick = useCallback((action: () => void) => { + action(); + }, []); + + const handleHeaderMouseDown = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(true); + setDragStart({ + y: e.clientY, + position: position, + }); + }, + [position] + ); + + const handleMouseMove = useCallback( + (e: MouseEvent) => { + if (!isDragging) return; + + const deltaY = e.clientY - dragStart.y; + const containerHeight = + containerRef.current?.parentElement?.clientHeight || window.innerHeight; + const deltaPercentage = (deltaY / containerHeight) * 100; + + const newPosition = Math.max( + 5, + Math.min(95, dragStart.position + deltaPercentage) + ); + setPosition(newPosition); + }, + [isDragging, dragStart, setPosition] + ); + + const handleMouseUp = useCallback(() => { + setIsDragging(false); + }, []); + + React.useEffect(() => { + if (isDragging) { + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + } + }, [isDragging, handleMouseMove, handleMouseUp]); + + return ( + + + + + + {actions + .filter((group) => !group.isHidden) + .map((group) => ( + + {group.label && {group.label}} + {group.actions + .filter((action) => action.isVisible !== false) + .map((action) => { + // Render custom component if provided + if (action.customComponent) { + return {action.customComponent}; + } + + // Render regular action button if no custom component is provided + return ( + + + {action.tooltip || action.label} + + {action.shortcut && ( + + {action.shortcut} + + )} + + } + placement="left" + arrow + disableHoverListener={false} + > + + !action.isDisabled && + handleActionClick(action.onClick) + } + isdisabled={String(action.isDisabled)} + isactive={String(action.isActive)} + size="small" + > + {action.icon} + + + ); + })} + + ))} + + + ); +}; diff --git a/app/packages/looker-3d/src/annotation/annotation-toolbar/CoordinateInputs.tsx b/app/packages/looker-3d/src/annotation/annotation-toolbar/CoordinateInputs.tsx new file mode 100644 index 00000000000..d8d98a87f21 --- /dev/null +++ b/app/packages/looker-3d/src/annotation/annotation-toolbar/CoordinateInputs.tsx @@ -0,0 +1,404 @@ +import { Box, TextField } from "@mui/material"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useRecoilState, useRecoilValue } from "recoil"; +import type { PolyLineProps } from "../../labels/polyline"; +import { + annotationPlaneAtom, + polylinePointTransformsAtom, + selectedLabelForAnnotationAtom, + selectedPolylineVertexAtom, +} from "../../state"; +import { + getVertexPosition, + updateDuplicateVertices, + applyTransformsToPolyline, +} from "../utils/polyline-utils"; + +interface CoordinateInputsProps { + className?: string; + hideTranslate?: boolean; + hideQuaternion?: boolean; +} + +interface CoordinateFieldProps { + label: string; + value: string; + onChange: (value: string) => void; + onFocus: () => void; + onBlur: () => void; +} + +const CoordinateField = ({ + label, + value, + onChange, + onFocus, + onBlur, +}: CoordinateFieldProps) => { + return ( + onChange(e.target.value)} + type="number" + inputProps={{ + step: "0.1", + style: { fontSize: "11px", padding: "4px 6px" }, + }} + sx={{ + "& .MuiInputLabel-root": { fontSize: "10px" }, + "& .MuiOutlinedInput-root": { height: "24px" }, + "& .MuiOutlinedInput-input": { padding: "4px 6px" }, + }} + /> + ); +}; + +export const PlaneCoordinateInputs = ({ + className, + hideTranslate = false, + hideQuaternion = true, +}: CoordinateInputsProps) => { + const [annotationPlane, setAnnotationPlane] = + useRecoilState(annotationPlaneAtom); + const [x, setX] = useState("0"); + const [y, setY] = useState("0"); + const [z, setZ] = useState("0"); + const [qx, setQx] = useState("0"); + const [qy, setQy] = useState("0"); + const [qz, setQz] = useState("0"); + const [qw, setQw] = useState("1"); + + const [isEditing, setIsEditing] = useState(false); + + useEffect(() => { + if (annotationPlane && !isEditing) { + setX(annotationPlane.position[0].toFixed(3)); + setY(annotationPlane.position[1].toFixed(3)); + setZ(annotationPlane.position[2].toFixed(3)); + setQx(annotationPlane.quaternion[0].toFixed(3)); + setQy(annotationPlane.quaternion[1].toFixed(3)); + setQz(annotationPlane.quaternion[2].toFixed(3)); + setQw(annotationPlane.quaternion[3].toFixed(3)); + } + }, [annotationPlane, isEditing]); + + const handlePositionChange = useCallback( + (axis: "x" | "y" | "z", value: string) => { + // Always update local state to allow blank values while editing + if (axis === "x") setX(value); + else if (axis === "y") setY(value); + else if (axis === "z") setZ(value); + + const numValue = parseFloat(value); + if (isNaN(numValue)) return; + + const newPosition: [number, number, number] = [ + ...annotationPlane.position, + ]; + if (axis === "x") newPosition[0] = numValue; + else if (axis === "y") newPosition[1] = numValue; + else if (axis === "z") newPosition[2] = numValue; + + setAnnotationPlane((prev) => ({ + ...prev, + position: newPosition, + })); + }, + [annotationPlane.position] + ); + + const handleQuaternionChange = useCallback( + (axis: "x" | "y" | "z" | "w", value: string) => { + // Always update local state to allow blank values while editing + if (axis === "x") setQx(value); + else if (axis === "y") setQy(value); + else if (axis === "z") setQz(value); + else if (axis === "w") setQw(value); + + const numValue = parseFloat(value); + if (isNaN(numValue)) return; + + const newQuaternion: [number, number, number, number] = [ + ...annotationPlane.quaternion, + ]; + if (axis === "x") newQuaternion[0] = numValue; + else if (axis === "y") newQuaternion[1] = numValue; + else if (axis === "z") newQuaternion[2] = numValue; + else if (axis === "w") newQuaternion[3] = numValue; + + setAnnotationPlane((prev) => ({ + ...prev, + quaternion: newQuaternion, + })); + }, + [annotationPlane.quaternion] + ); + + if (!annotationPlane.enabled) { + return null; + } + + return ( + + + {!hideTranslate && ( + <> + {annotationPlane.showX && ( + handlePositionChange("x", value)} + onFocus={() => setIsEditing(true)} + onBlur={() => setIsEditing(false)} + /> + )} + {annotationPlane.showY && ( + handlePositionChange("y", value)} + onFocus={() => setIsEditing(true)} + onBlur={() => setIsEditing(false)} + /> + )} + {annotationPlane.showZ && ( + handlePositionChange("z", value)} + onFocus={() => setIsEditing(true)} + onBlur={() => setIsEditing(false)} + /> + )} + + )} + {!hideQuaternion && ( + <> + handleQuaternionChange("x", value)} + onFocus={() => setIsEditing(true)} + onBlur={() => setIsEditing(false)} + /> + handleQuaternionChange("y", value)} + onFocus={() => setIsEditing(true)} + onBlur={() => setIsEditing(false)} + /> + handleQuaternionChange("z", value)} + onFocus={() => setIsEditing(true)} + onBlur={() => setIsEditing(false)} + /> + handleQuaternionChange("w", value)} + onFocus={() => setIsEditing(true)} + onBlur={() => setIsEditing(false)} + /> + + )} + + + ); +}; + +export const VertexCoordinateInputs = ({ + className, + hideTranslate = false, +}: CoordinateInputsProps) => { + const [selectedPoint, setSelectedPoint] = useRecoilState( + selectedPolylineVertexAtom + ); + const selectedLabel = useRecoilValue(selectedLabelForAnnotationAtom); + const [polylinePointTransforms, setPolylinePointTransforms] = useRecoilState( + polylinePointTransformsAtom + ); + const selectedPointPosition = useMemo(() => { + if (!selectedPoint || !selectedLabel) return null; + + const polylineLabel = selectedLabel as unknown as PolyLineProps; + const transforms = polylinePointTransforms[selectedPoint.labelId] || []; + + return getVertexPosition( + selectedPoint, + polylineLabel.points3d || [], + transforms + ); + }, [selectedPoint, selectedLabel, polylinePointTransforms]); + const [x, setX] = useState("0"); + const [y, setY] = useState("0"); + const [z, setZ] = useState("0"); + + const [isEditing, setIsEditing] = useState(false); + + useEffect(() => { + if (selectedPointPosition && !isEditing) { + setX(selectedPointPosition[0].toFixed(3)); + setY(selectedPointPosition[1].toFixed(3)); + setZ(selectedPointPosition[2].toFixed(3)); + } + }, [selectedPointPosition, isEditing]); + + const handleCoordinateChange = useCallback( + (axis: "x" | "y" | "z", value: string) => { + // Always update local state to allow blank values while editing + if (axis === "x") setX(value); + else if (axis === "y") setY(value); + else if (axis === "z") setZ(value); + + const numValue = parseFloat(value); + if (isNaN(numValue)) return; + + if (selectedPoint && selectedLabel) { + const { segmentIndex, pointIndex, labelId } = selectedPoint; + + setPolylinePointTransforms((prev) => { + const currentTransforms = prev[labelId] || []; + const polylineLabel = selectedLabel as unknown as PolyLineProps; + const originalPoints = polylineLabel.points3d || []; + + // Get current position (either from existing transform or original point) + let currentPosition: [number, number, number]; + const existingTransformIndex = currentTransforms.findIndex( + (transform) => + transform.segmentIndex === segmentIndex && + transform.pointIndex === pointIndex + ); + + if (existingTransformIndex >= 0) { + currentPosition = + currentTransforms[existingTransformIndex].position; + } else if (selectedPointPosition) { + currentPosition = selectedPointPosition; + } else { + // Fallback to original position from label + if ( + segmentIndex < originalPoints.length && + pointIndex < originalPoints[segmentIndex].length + ) { + currentPosition = originalPoints[segmentIndex][pointIndex] as [ + number, + number, + number + ]; + } else { + currentPosition = [0, 0, 0]; + } + } + + const newPosition: [number, number, number] = [...currentPosition]; + if (axis === "x") newPosition[0] = numValue; + else if (axis === "y") newPosition[1] = numValue; + else if (axis === "z") newPosition[2] = numValue; + + // Compute current effective points to find all shared vertices + const effectivePoints3d = applyTransformsToPolyline( + originalPoints, + currentTransforms + ); + + // Use updateDuplicateVertices to handle shared vertices + const newTransforms = updateDuplicateVertices( + currentPosition, + newPosition, + effectivePoints3d, + currentTransforms + ); + + return { ...prev, [labelId]: newTransforms }; + }); + + // Note: this is a hack because transform controls + // is updating in a buggy way when the point is moved + const prevSelectedPoint = selectedPoint; + setSelectedPoint(null); + setTimeout(() => { + setSelectedPoint(prevSelectedPoint); + }, 0); + } + }, + [ + selectedPoint, + selectedLabel, + selectedPointPosition, + setPolylinePointTransforms, + ] + ); + + if (!selectedPoint) { + return null; + } + + return ( + + + {!hideTranslate && ( + <> + handleCoordinateChange("x", value)} + onFocus={() => setIsEditing(true)} + onBlur={() => setIsEditing(false)} + /> + handleCoordinateChange("y", value)} + onFocus={() => setIsEditing(true)} + onBlur={() => setIsEditing(false)} + /> + handleCoordinateChange("z", value)} + onFocus={() => setIsEditing(true)} + onBlur={() => setIsEditing(false)} + /> + + )} + + + ); +}; diff --git a/app/packages/looker-3d/src/annotation/annotation-toolbar/index.ts b/app/packages/looker-3d/src/annotation/annotation-toolbar/index.ts new file mode 100644 index 00000000000..1f5f9e7b4fd --- /dev/null +++ b/app/packages/looker-3d/src/annotation/annotation-toolbar/index.ts @@ -0,0 +1,7 @@ +export { AnnotationToolbar } from "./AnnotationToolbar"; +export type { + AnnotationAction, + AnnotationActionGroup, + AnnotationToolbarProps, +} from "../types"; +export { useAnnotationActions } from "./useAnnotationActions"; diff --git a/app/packages/looker-3d/src/annotation/annotation-toolbar/useAnnotationActions.tsx b/app/packages/looker-3d/src/annotation/annotation-toolbar/useAnnotationActions.tsx new file mode 100644 index 00000000000..becfd061b94 --- /dev/null +++ b/app/packages/looker-3d/src/annotation/annotation-toolbar/useAnnotationActions.tsx @@ -0,0 +1,475 @@ +import { + Add, + Close, + Delete, + Edit, + OpenWith, + RotateRight, + Straighten, +} from "@mui/icons-material"; +import RectangleIcon from "@mui/icons-material/Rectangle"; +import RestartAltIcon from "@mui/icons-material/RestartAlt"; +import { Typography } from "@mui/material"; +import { useCallback, useEffect, useMemo } from "react"; +import { useRecoilState, useSetRecoilState } from "recoil"; +import * as THREE from "three"; +import MagnetIcon from "../../assets/icons/magnet.svg?react"; +import { useFo3dContext } from "../../fo3d/context"; +import { + annotationPlaneAtom, + currentArchetypeSelectedForTransformAtom, + editSegmentsModeAtom, + isSnapToAnnotationPlaneAtom, + polylinePointTransformsAtom, + segmentPolylineStateAtom, + selectedLabelForAnnotationAtom, + selectedPolylineVertexAtom, + snapCloseAutomaticallyAtom, + tempPolylinesAtom, + transformModeAtom, + transformSpaceAtom, +} from "../../state"; +import { getGridQuaternionFromUpVector } from "../../utils"; +import type { + AnnotationAction, + AnnotationActionGroup, + TransformMode, + TransformSpace, +} from "../types"; +import { deletePolylinePoint } from "../utils/polyline-delete"; +import { + PlaneCoordinateInputs, + VertexCoordinateInputs, +} from "./CoordinateInputs"; + +export const useAnnotationActions = () => { + const [selectedLabelForAnnotation, setSelectedLabelForAnnotation] = + useRecoilState(selectedLabelForAnnotationAtom); + const [ + currentArchetypeSelectedForTransform, + setCurrentArchetypeSelectedForTransform, + ] = useRecoilState(currentArchetypeSelectedForTransformAtom); + const [transformMode, setTransformMode] = useRecoilState(transformModeAtom); + const [transformSpace, setTransformSpace] = + useRecoilState(transformSpaceAtom); + const [selectedPoint, setSelectedPoint] = useRecoilState( + selectedPolylineVertexAtom + ); + const [segmentPolylineState, setSegmentPolylineState] = useRecoilState( + segmentPolylineStateAtom + ); + const [isSnapToAnnotationPlane, setIsSnapToAnnotationPlane] = useRecoilState( + isSnapToAnnotationPlaneAtom + ); + const [snapCloseAutomatically, setSnapCloseAutomatically] = useRecoilState( + snapCloseAutomaticallyAtom + ); + const [editSegmentsMode, setEditSegmentsMode] = + useRecoilState(editSegmentsModeAtom); + const [tempPolylines, setTempPolylines] = useRecoilState(tempPolylinesAtom); + const setPolylinePointTransforms = useSetRecoilState( + polylinePointTransformsAtom + ); + const [annotationPlane, setAnnotationPlane] = + useRecoilState(annotationPlaneAtom); + const { sceneBoundingBox, upVector } = useFo3dContext(); + + const handleTransformModeChange = useCallback( + (mode: TransformMode) => { + setTransformMode(mode); + }, + [setTransformMode] + ); + + const handleTransformSpaceChange = useCallback( + (space: TransformSpace) => { + setTransformSpace(space); + }, + [setTransformSpace] + ); + + const handleStartSegmentPolyline = useCallback(() => { + setSegmentPolylineState({ + isActive: true, + vertices: [], + currentMousePosition: null, + isClosed: false, + }); + }, [setSegmentPolylineState]); + + const handleCancelSegmentPolyline = useCallback(() => { + setSegmentPolylineState({ + isActive: false, + vertices: [], + currentMousePosition: null, + isClosed: false, + }); + }, [setSegmentPolylineState]); + + const handleClearTempPolylines = useCallback(() => { + setTempPolylines([]); + }, [setTempPolylines]); + + const handleDeleteSelectedPoint = useCallback(() => { + if (!selectedPoint) return; + + const { labelId, segmentIndex, pointIndex } = selectedPoint; + + setPolylinePointTransforms((prev) => { + const currentTransforms = prev[labelId] || []; + const result = deletePolylinePoint( + currentTransforms, + segmentIndex, + pointIndex + ); + + return { + ...prev, + [labelId]: result.newTransforms, + }; + }); + + setSelectedPoint(null); + setCurrentArchetypeSelectedForTransform(null); + }, [ + selectedPoint, + setPolylinePointTransforms, + setSelectedPoint, + setCurrentArchetypeSelectedForTransform, + ]); + + const handleDeleteEntireTransform = useCallback(() => { + if ( + !selectedLabelForAnnotation || + selectedLabelForAnnotation._cls !== "Polyline" + ) + return; + + const labelId = selectedLabelForAnnotation._id; + + setPolylinePointTransforms((prev) => { + const newTransforms = { ...prev }; + delete newTransforms[labelId]; + return newTransforms; + }); + + setSelectedLabelForAnnotation(null); + setCurrentArchetypeSelectedForTransform(null); + setSelectedPoint(null); + }, [ + selectedLabelForAnnotation, + setPolylinePointTransforms, + setSelectedLabelForAnnotation, + setCurrentArchetypeSelectedForTransform, + setSelectedPoint, + ]); + + const handleContextualDelete = useCallback(() => { + if (selectedPoint) { + handleDeleteSelectedPoint(); + } else if ( + selectedLabelForAnnotation && + selectedLabelForAnnotation._cls === "Polyline" + ) { + handleDeleteEntireTransform(); + } + }, [ + selectedPoint, + handleDeleteSelectedPoint, + selectedLabelForAnnotation, + handleDeleteEntireTransform, + ]); + + useEffect(() => { + const handler = (event: KeyboardEvent) => { + if (event.defaultPrevented) { + return; + } + + const target = event.target as HTMLElement | null; + if ( + target && + (target.isContentEditable || + target.tagName === "INPUT" || + target.tagName === "TEXTAREA") + ) { + return; + } + + if (event.key === "Delete" || event.key === "Backspace") { + handleContextualDelete(); + } + }; + + document.addEventListener("keydown", handler); + + return () => { + document.removeEventListener("keydown", handler); + }; + }, [handleContextualDelete]); + + const handleToggleAnnotationPlane = useCallback(() => { + if (!annotationPlane.enabled) { + if (sceneBoundingBox && upVector) { + const center = new THREE.Vector3(...annotationPlane.position); + + const quaternion = getGridQuaternionFromUpVector( + upVector, + new THREE.Vector3(0, 0, 1) + ); + + setAnnotationPlane({ + enabled: true, + position: [center.x, center.y, center.z], + quaternion: [quaternion.x, quaternion.y, quaternion.z, quaternion.w], + showX: false, + showY: false, + showZ: false, + }); + } else { + // Fallback to origin with Y-up + setAnnotationPlane({ + enabled: true, + position: [0, 0, 0], + quaternion: [0, 0, 0, 1], + showX: false, + showY: true, + showZ: false, + }); + } + } else { + setAnnotationPlane((prev) => ({ ...prev, enabled: false })); + setCurrentArchetypeSelectedForTransform(null); + } + }, [annotationPlane.enabled, sceneBoundingBox, upVector]); + + const handleToggleEditSegmentsMode = useCallback(() => { + setEditSegmentsMode(!editSegmentsMode); + // Deactivate other modes when entering edit segments mode + if (!editSegmentsMode) { + setSegmentPolylineState((prev) => ({ ...prev, isActive: false })); + } + }, [editSegmentsMode, setEditSegmentsMode, setSegmentPolylineState]); + + const actions: AnnotationActionGroup[] = useMemo(() => { + const baseActions: AnnotationActionGroup[] = [ + { + id: "edit-actions", + label: "Edit", + isHidden: selectedLabelForAnnotation === null, + actions: [ + { + id: "exit-edit-mode", + label: "Deselect", + icon: , + shortcut: "Esc", + tooltip: "Exit edit mode and deselect annotation", + isActive: false, + isVisible: selectedLabelForAnnotation !== null, + onClick: () => setSelectedLabelForAnnotation(null), + }, + ], + }, + { + id: "polyline-actions", + label: "Polyline", + actions: [ + { + id: "new-segment", + label: "New Segment", + icon: , + tooltip: "Add new polyline segment", + isActive: segmentPolylineState.isActive, + onClick: segmentPolylineState.isActive + ? handleCancelSegmentPolyline + : handleStartSegmentPolyline, + }, + { + id: "edit-segments", + label: "Edit Segments", + icon: , + tooltip: "Click on a segment to add a new vertex", + isActive: editSegmentsMode, + isVisible: selectedLabelForAnnotation !== null, + onClick: handleToggleEditSegmentsMode, + }, + { + id: "contextual-delete", + label: "Delete", + icon: , + shortcut: "Delete", + tooltip: selectedPoint + ? "Delete selected polyline point" + : "Delete polyline", + isActive: false, + isVisible: + selectedLabelForAnnotation !== null && + selectedLabelForAnnotation._cls === "Polyline", + onClick: handleContextualDelete, + }, + { + id: "snap-to-annotation-plane", + label: "Snap to Annotation Plane", + icon: ( + + ), + tooltip: + annotationPlane.enabled && !isSnapToAnnotationPlane + ? "Project new vertices to the annotation plane" + : "Project new vertices to the annotation plane whether or not it is enabled", + isActive: isSnapToAnnotationPlane || annotationPlane.enabled, + isDisabled: annotationPlane.enabled, + isVisible: true, + onClick: () => setIsSnapToAnnotationPlane(!isSnapToAnnotationPlane), + }, + { + id: "toggle-annotation-plane", + label: "Annotation Plane", + icon: , + tooltip: "Toggle annotation plane for z-drift prevention", + isActive: annotationPlane.enabled, + onClick: handleToggleAnnotationPlane, + }, + { + id: "snap-close-automatically", + label: "Snap Close Automatically", + icon: , + tooltip: + "When enabled, double-click closes polylines. When disabled, double-click ends segment at click location.", + isActive: snapCloseAutomatically, + onClick: () => setSnapCloseAutomatically(!snapCloseAutomatically), + }, + ], + }, + { + id: "transform-actions", + label: "Transform", + isHidden: !currentArchetypeSelectedForTransform, + actions: [ + { + id: "translate", + label: "Translate", + icon: , + tooltip: "Translate or move object", + isActive: + currentArchetypeSelectedForTransform && + transformMode === "translate", + isDisabled: !currentArchetypeSelectedForTransform, + onClick: () => handleTransformModeChange("translate"), + }, + { + id: "rotate", + label: "Rotate", + icon: , + tooltip: "Rotate object", + isVisible: currentArchetypeSelectedForTransform === "cuboid", + isActive: transformMode === "rotate", + onClick: () => handleTransformModeChange("rotate"), + }, + { + id: "scale", + label: "Scale", + icon: , + tooltip: "Scale object", + isActive: transformMode === "scale", + isVisible: currentArchetypeSelectedForTransform === "cuboid", + onClick: () => handleTransformModeChange("scale"), + }, + ], + }, + { + id: "space-actions", + label: "Space", + isHidden: + currentArchetypeSelectedForTransform !== "cuboid" || + (transformMode !== "translate" && transformMode !== "rotate"), + actions: [ + { + id: "world-space", + label: "World Space", + icon: W, + tooltip: "Transform in world space", + isActive: transformSpace === "world", + isVisible: + currentArchetypeSelectedForTransform === "cuboid" || + currentArchetypeSelectedForTransform === "annotation-plane", + onClick: () => handleTransformSpaceChange("world"), + }, + { + id: "local-space", + label: "Local Space", + icon: L, + tooltip: "Transform in local space", + isActive: transformSpace === "local", + isVisible: + currentArchetypeSelectedForTransform === "cuboid" || + currentArchetypeSelectedForTransform === "annotation-plane", + onClick: () => handleTransformSpaceChange("local"), + }, + ], + }, + ]; + + if ( + (selectedPoint && currentArchetypeSelectedForTransform === "point") || + currentArchetypeSelectedForTransform === "annotation-plane" + ) { + const coordinateInputAction: AnnotationAction = { + id: "coordinate-inputs-component", + label: "Coordinates", + icon: XYZ, + tooltip: "Edit point coordinates", + isActive: false, + isDisabled: false, + isVisible: true, + // No-op since this is a custom component + onClick: () => {}, + customComponent: + currentArchetypeSelectedForTransform === "annotation-plane" ? ( + + ) : ( + + ), + }; + + baseActions.push({ + id: "coordinate-inputs", + actions: [coordinateInputAction], + }); + } + + return baseActions; + }, [ + transformMode, + currentArchetypeSelectedForTransform, + handleTransformModeChange, + transformSpace, + handleContextualDelete, + handleTransformSpaceChange, + selectedLabelForAnnotation, + selectedPoint, + segmentPolylineState, + annotationPlane, + handleStartSegmentPolyline, + handleCancelSegmentPolyline, + handleToggleAnnotationPlane, + isSnapToAnnotationPlane, + snapCloseAutomatically, + editSegmentsMode, + handleToggleEditSegmentsMode, + ]); + + return { + actions, + transformMode, + transformSpace, + }; +}; diff --git a/app/packages/looker-3d/src/annotation/types.ts b/app/packages/looker-3d/src/annotation/types.ts new file mode 100644 index 00000000000..d00c018772f --- /dev/null +++ b/app/packages/looker-3d/src/annotation/types.ts @@ -0,0 +1,115 @@ +import { ReactNode } from "react"; + +export interface AnnotationAction { + id: string; + label: string; + icon: ReactNode; + shortcut?: string; + tooltip?: string; + isActive?: boolean; + isDisabled?: boolean; + isVisible?: boolean; + onClick: () => void; + customComponent?: ReactNode; +} + +export interface AnnotationActionGroup { + id: string; + label?: string; + isHidden?: boolean; + actions: AnnotationAction[]; +} + +export interface AnnotationToolbarProps { + className?: string; +} + +// Hover state for specific polyline points/segments +export interface HoveredPolylineInfo { + labelId: string; + segmentIndex: number; + // undefined means hovering over the segment, not a specific point + pointIndex?: number; +} + +// Transform control state +export type TransformMode = "translate" | "rotate" | "scale"; +export type TransformSpace = "world" | "local"; + +export interface Spatial { + position: [number, number, number]; + quaternion?: [number, number, number, number]; +} + +export interface SelectedPoint { + labelId: string; + segmentIndex: number; + pointIndex: number; +} + +// Transform data for HUD display +export interface TransformData { + // Delta X + dx?: number; + // Delta Y + dy?: number; + // Delta Z + dz?: number; + // Absolute world position X + x?: number; + // Absolute world position Y + y?: number; + // Absolute world position Z + z?: number; + // Dimensions X + dimensionX?: number; + // Dimensions Y + dimensionY?: number; + // Dimensions Z + dimensionZ?: number; + // Local rotation X (in degrees) + rotationX?: number; + // Local rotation Y (in degrees) + rotationY?: number; + // Local rotation Z (in degrees) + rotationZ?: number; +} + +// Transformed label data storage +export interface TransformedLabelData { + worldPosition: [number, number, number]; + dimensions: [number, number, number]; + localRotation: [number, number, number]; + worldRotation: [number, number, number]; +} + +// Polyline point transformations - stores modified points for each label +export interface PolylinePointTransform { + segmentIndex: number; + pointIndex: number; + position: [number, number, number]; +} + +export interface SegmentPolylineState { + isActive: boolean; + vertices: [number, number, number][]; + currentMousePosition: [number, number, number] | null; + isClosed: boolean; +} + +export interface TempPolyline { + id: string; + vertices: [number, number, number][]; + isClosed: boolean; + color: string; + lineWidth: number; +} + +export interface AnnotationPlaneState { + enabled: boolean; + position: [number, number, number]; + quaternion: [number, number, number, number]; + showX: boolean; + showY: boolean; + showZ: boolean; +} diff --git a/app/packages/looker-3d/src/annotation/usePolylineAnnotation.tsx b/app/packages/looker-3d/src/annotation/usePolylineAnnotation.tsx new file mode 100644 index 00000000000..807015c2787 --- /dev/null +++ b/app/packages/looker-3d/src/annotation/usePolylineAnnotation.tsx @@ -0,0 +1,411 @@ +import { ThreeEvent } from "@react-three/fiber"; +import chroma from "chroma-js"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useRecoilState, useRecoilValue, useSetRecoilState } from "recoil"; +import type { Vector3Tuple } from "three"; +import * as THREE from "three"; +import { + editSegmentsModeAtom, + hoveredLabelAtom, + hoveredPolylineInfoAtom, + polylineEffectivePointsAtom, + polylinePointTransformsAtom, + tempLabelTransformsAtom, +} from "../state"; +import { PolylinePointMarker } from "./PolylinePointMarker"; +import { + applyDeltaToAllPoints, + applyTransformsToPolyline, + findClickedSegment, + insertVertexInSegment, + updateDuplicateVertices, +} from "./utils/polyline-utils"; + +interface UsePolylineAnnotationProps { + label: any; + points3d: Vector3Tuple[][]; + strokeAndFillColor: string; + isAnnotateMode: boolean; + isSelectedForAnnotation: boolean; +} + +export const usePolylineAnnotation = ({ + label, + points3d, + strokeAndFillColor, + isAnnotateMode, + isSelectedForAnnotation, +}: UsePolylineAnnotationProps) => { + const [polylinePointTransforms, setPolylinePointTransforms] = useRecoilState( + polylinePointTransformsAtom + ); + + const editSegmentsMode = useRecoilValue(editSegmentsModeAtom); + + const setHoveredLabel = useSetRecoilState(hoveredLabelAtom); + const setHoveredPolylineInfo = useSetRecoilState(hoveredPolylineInfoAtom); + + const [effectivePoints3d, setPolylineEffectivePoints] = useRecoilState( + polylineEffectivePointsAtom(label._id) + ); + + const setTempPolylineTransforms = useSetRecoilState( + tempLabelTransformsAtom(label._id) + ); + + const transformControlsRef = useRef(null); + const contentRef = useRef(null); + const [startMatrix, setStartMatrix] = useState(null); + + const updateEffectivePoints = useCallback(() => { + const labelId = label._id; + const transforms = polylinePointTransforms[labelId] || []; + const result = applyTransformsToPolyline(points3d, transforms); + setPolylineEffectivePoints(result); + }, [ + polylinePointTransforms, + label._id, + points3d, + setPolylineEffectivePoints, + ]); + + useEffect(() => { + updateEffectivePoints(); + }, [updateEffectivePoints]); + + const centroid = useMemo(() => { + if (effectivePoints3d.length === 0) return [0, 0, 0]; + + const allPoints = effectivePoints3d.flat(); + if (allPoints.length === 0) return [0, 0, 0]; + + const sum = allPoints.reduce( + (acc, point) => [acc[0] + point[0], acc[1] + point[1], acc[2] + point[2]], + [0, 0, 0] + ); + + return [ + sum[0] / allPoints.length, + sum[1] / allPoints.length, + sum[2] / allPoints.length, + ] as [number, number, number]; + }, [effectivePoints3d]); + + const pointMarkers = useMemo(() => { + if (!isAnnotateMode || !isSelectedForAnnotation) return null; + + if (!strokeAndFillColor) return null; + + // This is to have contrast for annotation, + // Or else the point markers would be invisible with large line widths + const complementaryColor = chroma(strokeAndFillColor) + .set("hsl.h", "+180") + .hex(); + + // Global deduplication set to prevent multiple markers for the same physical vertex + // This ensures that shared vertices between segments only get one draggable marker + const visitedPoints = new Set(); + + return effectivePoints3d.flatMap((segment, segmentIndex) => { + return segment.map((point, pointIndex) => { + // Note: important to use a key based only on coordinates (not segment/point indices) + // This allows proper deduplication of vertices that appear in multiple segments + const key = `${point[0]}-${point[1]}-${point[2]}`; + + // Skip creating a marker if we've already seen this vertex position + if (visitedPoints.has(key)) { + return null; + } + + // Mark this vertex position as visited to prevent duplicates + visitedPoints.add(key); + + return ( + { + setPolylinePointTransforms((prev) => { + const labelId = label._id; + const currentTransforms = prev[labelId] || []; + + const newTransforms = updateDuplicateVertices( + point, + [newPosition.x, newPosition.y, newPosition.z], + effectivePoints3d, + currentTransforms + ); + + return { + ...prev, + [labelId]: newTransforms, + }; + }); + }} + pulsate={false} + /> + ); + }); + }); + }, [ + isAnnotateMode, + isSelectedForAnnotation, + effectivePoints3d, + label._id, + strokeAndFillColor, + setPolylinePointTransforms, + ]); + + const centroidMarker = useMemo(() => { + if (!isAnnotateMode || !isSelectedForAnnotation) return null; + + if (!strokeAndFillColor) return null; + + const centroidColor = chroma(strokeAndFillColor) + .set("hsl.h", "+180") + .brighten(1.5) + .hex(); + + return ( + + ); + }, [ + isAnnotateMode, + isSelectedForAnnotation, + centroid, + strokeAndFillColor, + label._id, + ]); + + const syncPolylineTransformationToTempStore = useCallback(() => { + const controls = transformControlsRef.current; + if (!controls) return; + + const grp = contentRef.current; + if (!grp) return; + + const worldPosition = grp.position.clone(); + + setTempPolylineTransforms({ + position: [worldPosition.x, worldPosition.y, worldPosition.z], + quaternion: grp.quaternion.toArray(), + }); + }, []); + + const handleTransformStart = useCallback(() => { + const grp = contentRef.current; + if (!grp) return; + + // Store the start matrix for computing delta later + setStartMatrix(grp.matrixWorld.clone()); + }, []); + + const handleTransformChange = useCallback(() => { + syncPolylineTransformationToTempStore(); + }, [syncPolylineTransformationToTempStore]); + + useEffect(() => { + return () => { + setTempPolylineTransforms(null); + }; + }, [label._id]); + + const handleTransformEnd = useCallback(() => { + const grp = contentRef.current; + if (!grp || !startMatrix) return; + + setTempPolylineTransforms(null); + + // Compute world-space delta from start and end matrices + const endMatrix = grp.matrixWorld.clone(); + const deltaMatrix = endMatrix + .clone() + .multiply(startMatrix.clone().invert()); + + // Extract position delta from the delta matrix + const deltaPosition = new THREE.Vector3(); + deltaPosition.setFromMatrixPosition(deltaMatrix); + + const worldDelta = deltaPosition; + + setPolylinePointTransforms((prev) => { + const labelId = label._id; + const currentTransforms = prev[labelId] || []; + + const newTransforms = applyDeltaToAllPoints( + effectivePoints3d, + points3d, + currentTransforms, + [worldDelta.x, worldDelta.y, worldDelta.z] + ); + + return { ...prev, [labelId]: newTransforms }; + }); + + // Reset group position to prevent double-application + // This is important because transform controls are applied to the group + // Whereas we create polylines from the effective points + if (contentRef.current) { + contentRef.current.position.set(0, 0, 0); + } + + setStartMatrix(null); + }, [ + label._id, + points3d, + effectivePoints3d, + setPolylinePointTransforms, + startMatrix, + ]); + + const handlePointerOver = useCallback(() => { + if (isAnnotateMode) { + setHoveredLabel(label); + } + }, [isAnnotateMode, setHoveredLabel, label]); + + const handlePointerOut = useCallback(() => { + if (isAnnotateMode) { + setHoveredLabel(null); + } + }, [isAnnotateMode, setHoveredLabel]); + + const handleSegmentPointerOver = useCallback( + (segmentIndex: number) => { + if (isAnnotateMode) { + setHoveredPolylineInfo({ + labelId: label._id, + segmentIndex, + // pointIndex is undefined when hovering over the segment + }); + + if (editSegmentsMode) { + document.body.style.cursor = "crosshair"; + } + } + }, + [isAnnotateMode, setHoveredPolylineInfo, label._id, editSegmentsMode] + ); + + const handleSegmentPointerOut = useCallback(() => { + if (isAnnotateMode) { + setHoveredPolylineInfo(null); + } + + if (!editSegmentsMode) { + document.body.style.cursor = "default"; + } + }, [isAnnotateMode, setHoveredPolylineInfo, editSegmentsMode]); + + const handleSegmentClick = useCallback( + (event: ThreeEvent) => { + if (!editSegmentsMode || !isSelectedForAnnotation || !isAnnotateMode) { + return; + } + + event.stopPropagation(); + + // Get the click position from the event + const clickPosition: Vector3Tuple = [ + event.point.x, + event.point.y, + event.point.z, + ]; + + // Find which segment was clicked + const clickResult = findClickedSegment( + effectivePoints3d, + clickPosition, + // Distance threshold + 0.2 + ); + + if (clickResult) { + const { segmentIndex, newVertexPosition } = clickResult; + + // Insert the new vertex into the segment + setPolylinePointTransforms((prev) => { + const labelId = label._id; + const currentTransforms = prev[labelId] || []; + + const newTransforms = insertVertexInSegment( + points3d, + currentTransforms, + segmentIndex, + newVertexPosition + ); + + // If newTransforms is null, it means the new vertex was too close to an existing vertex + // In that case, we don't update the transforms + if (newTransforms === null) { + return prev; + } + + return { + ...prev, + [labelId]: newTransforms, + }; + }); + } + }, + [ + editSegmentsMode, + isSelectedForAnnotation, + isAnnotateMode, + effectivePoints3d, + points3d, + label._id, + setPolylinePointTransforms, + ] + ); + + const markers = useMemo(() => { + if (!isAnnotateMode || !isSelectedForAnnotation) return null; + + return ( + <> + {pointMarkers} + {centroidMarker} + + ); + }, [isAnnotateMode, isSelectedForAnnotation, pointMarkers, centroidMarker]); + + return { + // State + effectivePoints3d, + centroid, + isAnnotateMode, + isSelectedForAnnotation, + + // Refs + transformControlsRef, + contentRef, + + // Markers + markers, + + // Handlers + handleTransformStart, + handleTransformChange, + handleTransformEnd, + handlePointerOver, + handlePointerOut, + handleSegmentPointerOver, + handleSegmentPointerOut, + handleSegmentClick, + }; +}; diff --git a/app/packages/looker-3d/src/annotation/utils/polyline-delete.test.ts b/app/packages/looker-3d/src/annotation/utils/polyline-delete.test.ts new file mode 100644 index 00000000000..700ea808d9b --- /dev/null +++ b/app/packages/looker-3d/src/annotation/utils/polyline-delete.test.ts @@ -0,0 +1,247 @@ +import { describe, expect, it } from "vitest"; +import type { PolylinePointTransform } from "../types"; +import { deletePolylinePoint } from "./polyline-delete"; + +describe("deletePolylinePoint", () => { + it("should return unchanged transforms when point doesn't exist", () => { + const transforms: PolylinePointTransform[] = [ + { segmentIndex: 0, pointIndex: 0, position: [0, 0, 0] }, + { segmentIndex: 0, pointIndex: 1, position: [1, 1, 1] }, + ]; + + const result = deletePolylinePoint(transforms, 1, 0); + + expect(result.newTransforms).toEqual(transforms); + expect(result.deletedSegmentIndices).toEqual([]); + expect(result.mergedSegmentIndex).toBeUndefined(); + }); + + it("should delete entire segment when point is only in one segment", () => { + const transforms: PolylinePointTransform[] = [ + { segmentIndex: 0, pointIndex: 0, position: [0, 0, 0] }, + { segmentIndex: 0, pointIndex: 1, position: [1, 1, 1] }, + { segmentIndex: 1, pointIndex: 0, position: [2, 2, 2] }, + { segmentIndex: 1, pointIndex: 1, position: [3, 3, 3] }, + ]; + + const result = deletePolylinePoint(transforms, 0, 0); + + expect(result.newTransforms).toEqual([ + { segmentIndex: 1, pointIndex: 0, position: [2, 2, 2] }, + { segmentIndex: 1, pointIndex: 1, position: [3, 3, 3] }, + ]); + expect(result.deletedSegmentIndices).toEqual([0]); + expect(result.mergedSegmentIndex).toBeUndefined(); + }); + + it("should merge segments when point is shared between two segments", () => { + const sharedPoint: [number, number, number] = [1, 1, 1]; + const transforms: PolylinePointTransform[] = [ + // Segment 0: [0,0,0] -> [1,1,1] + { segmentIndex: 0, pointIndex: 0, position: [0, 0, 0] }, + { segmentIndex: 0, pointIndex: 1, position: sharedPoint }, + // Segment 1: [1,1,1] -> [2,2,2] + { segmentIndex: 1, pointIndex: 0, position: sharedPoint }, + { segmentIndex: 1, pointIndex: 1, position: [2, 2, 2] }, + ]; + + const result = deletePolylinePoint(transforms, 0, 1); + + expect(result.newTransforms).toEqual([ + { segmentIndex: 0, pointIndex: 0, position: [0, 0, 0] }, + { segmentIndex: 0, pointIndex: 1, position: [2, 2, 2] }, + ]); + expect(result.deletedSegmentIndices).toEqual([0, 1]); + expect(result.mergedSegmentIndex).toBe(0); + }); + + it("should merge segments with different segment indices", () => { + const sharedPoint: [number, number, number] = [1, 1, 1]; + const transforms: PolylinePointTransform[] = [ + // Segment 2: [0,0,0] -> [1,1,1] + { segmentIndex: 2, pointIndex: 0, position: [0, 0, 0] }, + { segmentIndex: 2, pointIndex: 1, position: sharedPoint }, + // Segment 5: [1,1,1] -> [2,2,2] + { segmentIndex: 5, pointIndex: 0, position: sharedPoint }, + { segmentIndex: 5, pointIndex: 1, position: [2, 2, 2] }, + ]; + + const result = deletePolylinePoint(transforms, 2, 1); + + expect(result.newTransforms).toEqual([ + { segmentIndex: 2, pointIndex: 0, position: [0, 0, 0] }, + { segmentIndex: 2, pointIndex: 1, position: [2, 2, 2] }, + ]); + expect(result.deletedSegmentIndices).toEqual([2, 5]); + expect(result.mergedSegmentIndex).toBe(2); + }); + + it("should only remove point from selected segment when point is in more than 2 segments", () => { + const sharedPoint: [number, number, number] = [1, 1, 1]; + const transforms: PolylinePointTransform[] = [ + // Segment 0: [0,0,0] -> [1,1,1] + { segmentIndex: 0, pointIndex: 0, position: [0, 0, 0] }, + { segmentIndex: 0, pointIndex: 1, position: sharedPoint }, + // Segment 1: [1,1,1] -> [2,2,2] + { segmentIndex: 1, pointIndex: 0, position: sharedPoint }, + { segmentIndex: 1, pointIndex: 1, position: [2, 2, 2] }, + // Segment 2: [1,1,1] -> [3,3,3] + { segmentIndex: 2, pointIndex: 0, position: sharedPoint }, + { segmentIndex: 2, pointIndex: 1, position: [3, 3, 3] }, + ]; + + const result = deletePolylinePoint(transforms, 0, 1); + + expect(result.newTransforms).toEqual([ + { segmentIndex: 0, pointIndex: 0, position: [0, 0, 0] }, + { segmentIndex: 1, pointIndex: 0, position: sharedPoint }, + { segmentIndex: 1, pointIndex: 1, position: [2, 2, 2] }, + { segmentIndex: 2, pointIndex: 0, position: sharedPoint }, + { segmentIndex: 2, pointIndex: 1, position: [3, 3, 3] }, + ]); + expect(result.deletedSegmentIndices).toEqual([]); + expect(result.mergedSegmentIndex).toBeUndefined(); + }); + + it("should handle edge case where segments cannot be merged due to missing other points", () => { + const sharedPoint: [number, number, number] = [1, 1, 1]; + const transforms: PolylinePointTransform[] = [ + // Segment 0: only has the shared point (invalid segment) + { segmentIndex: 0, pointIndex: 1, position: sharedPoint }, + // Segment 1: [1,1,1] -> [2,2,2] + { segmentIndex: 1, pointIndex: 0, position: sharedPoint }, + { segmentIndex: 1, pointIndex: 1, position: [2, 2, 2] }, + ]; + + const result = deletePolylinePoint(transforms, 0, 1); + + // The function should detect that there are 2 segments sharing the point + // and try to merge them, but since segment 0 only has one point, + // it should fall back to just removing the point from the selected segment + expect(result.newTransforms).toHaveLength(3); + expect(result.deletedSegmentIndices).toEqual([]); + expect(result.mergedSegmentIndex).toBeUndefined(); + + // The result should still contain the shared point from segment 1 + expect( + result.newTransforms.some( + (t) => + t.segmentIndex === 1 && + t.pointIndex === 0 && + t.position[0] === 1 && + t.position[1] === 1 && + t.position[2] === 1 + ) + ).toBe(true); + }); + + it("should handle empty transforms array", () => { + const result = deletePolylinePoint([], 0, 0); + + expect(result.newTransforms).toEqual([]); + expect(result.deletedSegmentIndices).toEqual([]); + expect(result.mergedSegmentIndex).toBeUndefined(); + }); + + it("should handle single point segment deletion", () => { + const transforms: PolylinePointTransform[] = [ + { segmentIndex: 0, pointIndex: 0, position: [0, 0, 0] }, + ]; + + const result = deletePolylinePoint(transforms, 0, 0); + + expect(result.newTransforms).toEqual([]); + expect(result.deletedSegmentIndices).toEqual([0]); + expect(result.mergedSegmentIndex).toBeUndefined(); + }); + + it("should preserve other segments when deleting one segment", () => { + const transforms: PolylinePointTransform[] = [ + // Segment 0: [0,0,0] -> [1,1,1] + { segmentIndex: 0, pointIndex: 0, position: [0, 0, 0] }, + { segmentIndex: 0, pointIndex: 1, position: [1, 1, 1] }, + // Segment 1: [2,2,2] -> [3,3,3] + { segmentIndex: 1, pointIndex: 0, position: [2, 2, 2] }, + { segmentIndex: 1, pointIndex: 1, position: [3, 3, 3] }, + ]; + + const result = deletePolylinePoint(transforms, 0, 0); + + expect(result.newTransforms).toEqual([ + { segmentIndex: 1, pointIndex: 0, position: [2, 2, 2] }, + { segmentIndex: 1, pointIndex: 1, position: [3, 3, 3] }, + ]); + expect(result.deletedSegmentIndices).toEqual([0]); + }); + + it("should handle complex merging scenario with multiple segments", () => { + const sharedPoint: [number, number, number] = [1, 1, 1]; + const transforms: PolylinePointTransform[] = [ + // Segment 0: [0,0,0] -> [1,1,1] + { segmentIndex: 0, pointIndex: 0, position: [0, 0, 0] }, + { segmentIndex: 0, pointIndex: 1, position: sharedPoint }, + // Segment 1: [1,1,1] -> [2,2,2] + { segmentIndex: 1, pointIndex: 0, position: sharedPoint }, + { segmentIndex: 1, pointIndex: 1, position: [2, 2, 2] }, + // Segment 2: [3,3,3] -> [4,4,4] (unrelated) + { segmentIndex: 2, pointIndex: 0, position: [3, 3, 3] }, + { segmentIndex: 2, pointIndex: 1, position: [4, 4, 4] }, + ]; + + const result = deletePolylinePoint(transforms, 0, 1); + + // The result should contain the merged segment and the unrelated segment + // The order might vary, so let's check the content more flexibly + expect(result.newTransforms).toHaveLength(4); + expect(result.deletedSegmentIndices).toEqual([0, 1]); + expect(result.mergedSegmentIndex).toBe(0); + + // Check that we have the merged segment [0,0,0] -> [2,2,2] + const mergedSegment = result.newTransforms.filter( + (t) => t.segmentIndex === 0 + ); + expect(mergedSegment).toHaveLength(2); + expect( + mergedSegment.some( + (t) => + t.pointIndex === 0 && + t.position[0] === 0 && + t.position[1] === 0 && + t.position[2] === 0 + ) + ).toBe(true); + expect( + mergedSegment.some( + (t) => + t.pointIndex === 1 && + t.position[0] === 2 && + t.position[1] === 2 && + t.position[2] === 2 + ) + ).toBe(true); + + // Check that we have the unrelated segment [3,3,3] -> [4,4,4] + const unrelatedSegment = result.newTransforms.filter( + (t) => t.segmentIndex === 2 + ); + expect(unrelatedSegment).toHaveLength(2); + expect( + unrelatedSegment.some( + (t) => + t.pointIndex === 0 && + t.position[0] === 3 && + t.position[1] === 3 && + t.position[2] === 3 + ) + ).toBe(true); + expect( + unrelatedSegment.some( + (t) => + t.pointIndex === 1 && + t.position[0] === 4 && + t.position[1] === 4 && + t.position[2] === 4 + ) + ).toBe(true); + }); +}); diff --git a/app/packages/looker-3d/src/annotation/utils/polyline-delete.ts b/app/packages/looker-3d/src/annotation/utils/polyline-delete.ts new file mode 100644 index 00000000000..6a33b4b3b6c --- /dev/null +++ b/app/packages/looker-3d/src/annotation/utils/polyline-delete.ts @@ -0,0 +1,133 @@ +import type { PolylinePointTransform } from "../types"; + +export interface DeletePointResult { + newTransforms: PolylinePointTransform[]; + deletedSegmentIndices: number[]; + mergedSegmentIndex?: number; +} + +/** + * Deletes a polyline point and handles segment merging logic. + * + * @param currentTransforms - Current polyline point transforms + * @param segmentIndex - Index of the segment containing the point to delete + * @param pointIndex - Index of the point within the segment to delete + * @returns Object containing new transforms and metadata about the operation + */ +export function deletePolylinePoint( + currentTransforms: PolylinePointTransform[], + segmentIndex: number, + pointIndex: number +): DeletePointResult { + // Find the position of the point being deleted + const pointToDelete = currentTransforms.find( + (transform) => + transform.segmentIndex === segmentIndex && + transform.pointIndex === pointIndex + ); + + if (!pointToDelete) { + // Point doesn't exist in transforms, nothing to delete + return { + newTransforms: currentTransforms, + deletedSegmentIndices: [], + }; + } + + const pointPosition = pointToDelete.position; + + // Find all segments that share this point position + const segmentsWithThisPoint = currentTransforms.filter( + (transform) => + transform.position[0] === pointPosition[0] && + transform.position[1] === pointPosition[1] && + transform.position[2] === pointPosition[2] + ); + + // Group by segment index + const segmentsMap = new Map(); + segmentsWithThisPoint.forEach((transform) => { + if (!segmentsMap.has(transform.segmentIndex)) { + segmentsMap.set(transform.segmentIndex, []); + } + segmentsMap.get(transform.segmentIndex)!.push(transform); + }); + + let newTransforms = [...currentTransforms]; + const deletedSegmentIndices: number[] = []; + let mergedSegmentIndex: number | undefined; + + if (segmentsMap.size === 1) { + // Point is only in one segment, delete the entire segment + const segmentToDelete = segmentsMap.keys().next().value; + deletedSegmentIndices.push(segmentToDelete); + newTransforms = newTransforms.filter( + (transform) => transform.segmentIndex !== segmentToDelete + ); + } else if (segmentsMap.size === 2) { + // Point is shared between two segments, merge them + const segmentIndices = Array.from(segmentsMap.keys()); + const segment1 = segmentIndices[0]; + const segment2 = segmentIndices[1]; + + // Get the other points from both segments + const segment1Points = currentTransforms.filter( + (transform) => transform.segmentIndex === segment1 + ); + const segment2Points = currentTransforms.filter( + (transform) => transform.segmentIndex === segment2 + ); + + // Find the non-shared points + const segment1OtherPoint = segment1Points.find( + (transform) => + transform.position[0] !== pointPosition[0] || + transform.position[1] !== pointPosition[1] || + transform.position[2] !== pointPosition[2] + ); + const segment2OtherPoint = segment2Points.find( + (transform) => + transform.position[0] !== pointPosition[0] || + transform.position[1] !== pointPosition[1] || + transform.position[2] !== pointPosition[2] + ); + + if (segment1OtherPoint && segment2OtherPoint) { + // Remove both old segments + deletedSegmentIndices.push(segment1, segment2); + newTransforms = newTransforms.filter( + (transform) => + transform.segmentIndex !== segment1 && + transform.segmentIndex !== segment2 + ); + + // Create a new merged segment + mergedSegmentIndex = Math.min(segment1, segment2); + newTransforms.push({ + segmentIndex: mergedSegmentIndex, + pointIndex: 0, + position: segment1OtherPoint.position, + }); + newTransforms.push({ + segmentIndex: mergedSegmentIndex, + pointIndex: 1, + position: segment2OtherPoint.position, + }); + } + } else { + // Point is in more than 2 segments, just remove the point from the selected segment + newTransforms = newTransforms.filter( + (transform) => + !( + transform.segmentIndex === segmentIndex && + transform.pointIndex === pointIndex + ) + ); + } + + return { + newTransforms, + deletedSegmentIndices, + mergedSegmentIndex, + }; +} diff --git a/app/packages/looker-3d/src/annotation/utils/polyline-utils.test.ts b/app/packages/looker-3d/src/annotation/utils/polyline-utils.test.ts new file mode 100644 index 00000000000..06874caa5f6 --- /dev/null +++ b/app/packages/looker-3d/src/annotation/utils/polyline-utils.test.ts @@ -0,0 +1,1555 @@ +import type { Vector3Tuple } from "three"; +import { describe, expect, it } from "vitest"; +import { SNAP_TOLERANCE } from "../../constants"; +import type { PolylinePointTransform } from "../types"; +import { + applyDeltaToAllPoints, + applyTransformsToPolyline, + calculatePolylineCentroid, + findClickedSegment, + findClosestPointOnSegment, + findSharedPointSegments, + getCurrentVertexPosition, + getVertexPosition, + insertVertexInSegment, + shouldClosePolylineLoop, + updateDuplicateVertices, +} from "./polyline-utils"; + +// Import the positionsEqual function for testing (it's not exported, so we'll test it indirectly) +// We'll test it through findSharedPointSegments which uses it internally + +describe("getVertexPosition", () => { + const mockPoints3d: Vector3Tuple[][] = [ + [ + [0, 0, 0], + [1, 1, 1], + ], + [ + [2, 2, 2], + [3, 3, 3], + ], + ]; + + it("returns transformed position when transform exists", () => { + const transforms: PolylinePointTransform[] = [ + { segmentIndex: 0, pointIndex: 0, position: [5, 5, 5] }, + ]; + const selectedPoint = { segmentIndex: 0, pointIndex: 0, labelId: "test" }; + + const result = getVertexPosition(selectedPoint, mockPoints3d, transforms); + + expect(result).toEqual([5, 5, 5]); + }); + + it("returns original position when no transform exists", () => { + const transforms: PolylinePointTransform[] = []; + const selectedPoint = { segmentIndex: 0, pointIndex: 0, labelId: "test" }; + + const result = getVertexPosition(selectedPoint, mockPoints3d, transforms); + + expect(result).toEqual([0, 0, 0]); + }); + + it("returns null for invalid segment index", () => { + const transforms: PolylinePointTransform[] = []; + const selectedPoint = { segmentIndex: 5, pointIndex: 0, labelId: "test" }; + + const result = getVertexPosition(selectedPoint, mockPoints3d, transforms); + + expect(result).toBeNull(); + }); + + it("returns null for invalid point index", () => { + const transforms: PolylinePointTransform[] = []; + const selectedPoint = { segmentIndex: 0, pointIndex: 5, labelId: "test" }; + + const result = getVertexPosition(selectedPoint, mockPoints3d, transforms); + + expect(result).toBeNull(); + }); + + it("handles empty transforms array", () => { + const transforms: PolylinePointTransform[] = []; + const selectedPoint = { segmentIndex: 0, pointIndex: 0, labelId: "test" }; + + const result = getVertexPosition(selectedPoint, mockPoints3d, transforms); + + expect(result).toEqual([0, 0, 0]); + }); + + it("handles empty points3d array", () => { + const transforms: PolylinePointTransform[] = []; + const selectedPoint = { segmentIndex: 0, pointIndex: 0, labelId: "test" }; + + const result = getVertexPosition(selectedPoint, [], transforms); + + expect(result).toBeNull(); + }); +}); + +describe("calculatePolylineCentroid", () => { + it("calculates correct centroid for single segment", () => { + const points3d: Vector3Tuple[][] = [ + [ + [0, 0, 0], + [2, 2, 2], + [4, 4, 4], + ], + ]; + + const result = calculatePolylineCentroid(points3d); + + expect(result).toEqual([2, 2, 2]); + }); + + it("calculates correct centroid for multiple segments", () => { + const points3d: Vector3Tuple[][] = [ + [ + [0, 0, 0], + [2, 2, 2], + ], + [ + [4, 4, 4], + [6, 6, 6], + ], + ]; + + const result = calculatePolylineCentroid(points3d); + + expect(result).toEqual([3, 3, 3]); + }); + + it("returns [0,0,0] for empty points array", () => { + const result = calculatePolylineCentroid([]); + + expect(result).toEqual([0, 0, 0]); + }); + + it("handles single point", () => { + const points3d: Vector3Tuple[][] = [[[5, 10, 15]]]; + + const result = calculatePolylineCentroid(points3d); + + expect(result).toEqual([5, 10, 15]); + }); + + it("handles points with negative coordinates", () => { + const points3d: Vector3Tuple[][] = [ + [ + [-1, -2, -3], + [1, 2, 3], + ], + ]; + + const result = calculatePolylineCentroid(points3d); + + expect(result).toEqual([0, 0, 0]); + }); + + it("handles very large coordinate values", () => { + const points3d: Vector3Tuple[][] = [ + [ + [1000000, 2000000, 3000000], + [2000000, 4000000, 6000000], + ], + ]; + + const result = calculatePolylineCentroid(points3d); + + expect(result).toEqual([1500000, 3000000, 4500000]); + }); +}); + +describe("findSharedPointSegments", () => { + it("finds all segments sharing a point", () => { + const transforms: PolylinePointTransform[] = [ + { segmentIndex: 0, pointIndex: 1, position: [1, 1, 1] }, + { segmentIndex: 1, pointIndex: 0, position: [1, 1, 1] }, + { segmentIndex: 2, pointIndex: 0, position: [2, 2, 2] }, + ]; + const position: [number, number, number] = [1, 1, 1]; + + const result = findSharedPointSegments(transforms, position); + + expect(result).toEqual([0, 1]); + }); + + it("returns empty array when no segments share point", () => { + const transforms: PolylinePointTransform[] = [ + { segmentIndex: 0, pointIndex: 0, position: [1, 1, 1] }, + ]; + const position: [number, number, number] = [2, 2, 2]; + + const result = findSharedPointSegments(transforms, position); + + expect(result).toEqual([]); + }); + + it("handles floating point precision issues with epsilon tolerance", () => { + const transforms: PolylinePointTransform[] = [ + { segmentIndex: 0, pointIndex: 0, position: [0.1, 0.2, 0.3] }, + { segmentIndex: 1, pointIndex: 0, position: [0.1, 0.2, 0.3] }, + ]; + const position: [number, number, number] = [0.1, 0.2, 0.3]; + + const result = findSharedPointSegments(transforms, position); + + expect(result).toEqual([0, 1]); + }); + + it("handles floating point precision with small differences within epsilon", () => { + const transforms: PolylinePointTransform[] = [ + { segmentIndex: 0, pointIndex: 0, position: [0.1, 0.2, 0.3] }, + { + segmentIndex: 1, + pointIndex: 0, + position: [0.1000001, 0.2000001, 0.3000001], + }, + { segmentIndex: 2, pointIndex: 0, position: [0.1, 0.2, 0.3] }, + ]; + const position: [number, number, number] = [0.1, 0.2, 0.3]; + + const result = findSharedPointSegments(transforms, position); + + expect(result).toEqual([0, 1, 2]); + }); + + it("rejects positions with differences larger than epsilon", () => { + const transforms: PolylinePointTransform[] = [ + { segmentIndex: 0, pointIndex: 0, position: [0.1, 0.2, 0.3] }, + { segmentIndex: 1, pointIndex: 0, position: [0.1, 0.2, 0.3] }, + ]; + const position: [number, number, number] = [0.1, 0.2, 0.4]; // Different Z coordinate + + const result = findSharedPointSegments(transforms, position); + + expect(result).toEqual([]); + }); + + it("handles very small epsilon differences", () => { + const transforms: PolylinePointTransform[] = [ + { segmentIndex: 0, pointIndex: 0, position: [1.0, 2.0, 3.0] }, + { + segmentIndex: 1, + pointIndex: 0, + position: [1.0000005, 2.0000005, 3.0000005], + }, + ]; + const position: [number, number, number] = [1.0, 2.0, 3.0]; + + const result = findSharedPointSegments(transforms, position); + + expect(result).toEqual([0, 1]); + }); + + it("handles negative coordinates with epsilon tolerance", () => { + const transforms: PolylinePointTransform[] = [ + { segmentIndex: 0, pointIndex: 0, position: [-1.0, -2.0, -3.0] }, + { + segmentIndex: 1, + pointIndex: 0, + position: [-1.0000001, -2.0000001, -3.0000001], + }, + ]; + const position: [number, number, number] = [-1.0, -2.0, -3.0]; + + const result = findSharedPointSegments(transforms, position); + + expect(result).toEqual([0, 1]); + }); + + it("handles duplicate transforms", () => { + const transforms: PolylinePointTransform[] = [ + { segmentIndex: 0, pointIndex: 0, position: [1, 1, 1] }, + { segmentIndex: 0, pointIndex: 0, position: [1, 1, 1] }, + ]; + const position: [number, number, number] = [1, 1, 1]; + + const result = findSharedPointSegments(transforms, position); + + expect(result).toEqual([0]); + }); +}); + +describe("shouldClosePolylineLoop", () => { + it("returns true when within snap tolerance of first vertex", () => { + const vertices: Vector3Tuple[] = [ + [0, 0, 0], + [1, 1, 1], + [2, 2, 2], + ]; + const currentPosition: Vector3Tuple = [0.1, 0.1, 0.1]; + + const result = shouldClosePolylineLoop(vertices, currentPosition, 0.5); + + expect(result).toBe(true); + }); + + it("returns false when outside snap tolerance", () => { + const vertices: Vector3Tuple[] = [ + [0, 0, 0], + [1, 1, 1], + [2, 2, 2], + ]; + const currentPosition: Vector3Tuple = [5, 5, 5]; + + const result = shouldClosePolylineLoop(vertices, currentPosition, 0.5); + + expect(result).toBe(false); + }); + + it("returns false when less than 3 vertices", () => { + const vertices: Vector3Tuple[] = [ + [0, 0, 0], + [1, 1, 1], + ]; + const currentPosition: Vector3Tuple = [0, 0, 0]; + + const result = shouldClosePolylineLoop(vertices, currentPosition); + + expect(result).toBe(false); + }); + + it("returns false for empty vertices array", () => { + const vertices: Vector3Tuple[] = []; + const currentPosition: Vector3Tuple = [0, 0, 0]; + + const result = shouldClosePolylineLoop(vertices, currentPosition); + + expect(result).toBe(false); + }); + + it("handles exact position match", () => { + const vertices: Vector3Tuple[] = [ + [0, 0, 0], + [1, 1, 1], + [2, 2, 2], + ]; + const currentPosition: Vector3Tuple = [0, 0, 0]; + + const result = shouldClosePolylineLoop(vertices, currentPosition); + + expect(result).toBe(true); + }); + + it("handles edge case at exact tolerance boundary", () => { + const vertices: Vector3Tuple[] = [ + [0, 0, 0], + [1, 1, 1], + [2, 2, 2], + ]; + const currentPosition: Vector3Tuple = [SNAP_TOLERANCE, 0, 0]; + + const result = shouldClosePolylineLoop(vertices, currentPosition); + + expect(result).toBe(false); + }); +}); + +describe("getCurrentVertexPosition", () => { + const mockOriginalPoints: Vector3Tuple[][] = [ + [ + [0, 0, 0], + [1, 1, 1], + ], + [ + [2, 2, 2], + [3, 3, 3], + ], + ]; + + it("returns transformed position when available", () => { + const transforms: PolylinePointTransform[] = [ + { segmentIndex: 0, pointIndex: 0, position: [5, 5, 5] }, + ]; + + const result = getCurrentVertexPosition( + 0, + 0, + mockOriginalPoints, + transforms + ); + + expect(result).toEqual([5, 5, 5]); + }); + + it("falls back to original position", () => { + const transforms: PolylinePointTransform[] = []; + + const result = getCurrentVertexPosition( + 0, + 0, + mockOriginalPoints, + transforms + ); + + expect(result).toEqual([0, 0, 0]); + }); + + it("handles missing segment in original points", () => { + const transforms: PolylinePointTransform[] = []; + + const result = getCurrentVertexPosition( + 5, + 0, + mockOriginalPoints, + transforms + ); + + expect(result).toEqual([0, 0, 0]); + }); + + it("handles missing point in segment", () => { + const transforms: PolylinePointTransform[] = []; + + const result = getCurrentVertexPosition( + 0, + 5, + mockOriginalPoints, + transforms + ); + + expect(result).toEqual([0, 0, 0]); + }); + + it("returns sensible default for completely invalid indices", () => { + const transforms: PolylinePointTransform[] = []; + + const result = getCurrentVertexPosition( + 5, + 5, + mockOriginalPoints, + transforms + ); + + expect(result).toEqual([0, 0, 0]); + }); +}); + +describe("applyTransformsToPolyline", () => { + it("applies single transform correctly", () => { + const originalPoints: Vector3Tuple[][] = [ + [ + [0, 0, 0], + [1, 1, 1], + ], + ]; + const transforms: PolylinePointTransform[] = [ + { segmentIndex: 0, pointIndex: 0, position: [5, 5, 5] }, + ]; + + const result = applyTransformsToPolyline(originalPoints, transforms); + + expect(result).toEqual([ + [ + [5, 5, 5], + [1, 1, 1], + ], + ]); + }); + + it("applies multiple transforms to different points", () => { + const originalPoints: Vector3Tuple[][] = [ + [ + [0, 0, 0], + [1, 1, 1], + ], + [ + [2, 2, 2], + [3, 3, 3], + ], + ]; + const transforms: PolylinePointTransform[] = [ + { segmentIndex: 0, pointIndex: 0, position: [5, 5, 5] }, + { segmentIndex: 1, pointIndex: 1, position: [6, 6, 6] }, + ]; + + const result = applyTransformsToPolyline(originalPoints, transforms); + + expect(result).toEqual([ + [ + [5, 5, 5], + [1, 1, 1], + ], + [ + [2, 2, 2], + [6, 6, 6], + ], + ]); + }); + + it("handles transforms to non-existent segments (creates default)", () => { + const originalPoints: Vector3Tuple[][] = [ + [ + [0, 0, 0], + [1, 1, 1], + ], + ]; + const transforms: PolylinePointTransform[] = [ + { segmentIndex: 2, pointIndex: 0, position: [5, 5, 5] }, + ]; + + const result = applyTransformsToPolyline(originalPoints, transforms); + + expect(result).toEqual([ + [ + [0, 0, 0], + [1, 1, 1], + ], + undefined, + [ + [5, 5, 5], + [0, 0, 0], + ], + ]); + }); + + it("handles empty transforms array", () => { + const originalPoints: Vector3Tuple[][] = [ + [ + [0, 0, 0], + [1, 1, 1], + ], + ]; + const transforms: PolylinePointTransform[] = []; + + const result = applyTransformsToPolyline(originalPoints, transforms); + + expect(result).toEqual([ + [ + [0, 0, 0], + [1, 1, 1], + ], + ]); + }); + + it("preserves untransformed points", () => { + const originalPoints: Vector3Tuple[][] = [ + [ + [0, 0, 0], + [1, 1, 1], + [2, 2, 2], + ], + ]; + const transforms: PolylinePointTransform[] = [ + { segmentIndex: 0, pointIndex: 1, position: [5, 5, 5] }, + ]; + + const result = applyTransformsToPolyline(originalPoints, transforms); + + expect(result).toEqual([ + [ + [0, 0, 0], + [5, 5, 5], + [2, 2, 2], + ], + ]); + }); +}); + +describe("applyDeltaToAllPoints", () => { + it("applies delta to all points correctly", () => { + const effectivePoints: Vector3Tuple[][] = [ + [ + [0, 0, 0], + [1, 1, 1], + ], + ]; + const originalPoints: Vector3Tuple[][] = [ + [ + [0, 0, 0], + [1, 1, 1], + ], + ]; + const currentTransforms: PolylinePointTransform[] = []; + const delta: Vector3Tuple = [2, 2, 2]; + + const result = applyDeltaToAllPoints( + effectivePoints, + originalPoints, + currentTransforms, + delta + ); + + expect(result).toEqual([ + { segmentIndex: 0, pointIndex: 0, position: [2, 2, 2] }, + { segmentIndex: 0, pointIndex: 1, position: [3, 3, 3] }, + ]); + }); + + it("combines with existing transforms", () => { + const effectivePoints: Vector3Tuple[][] = [ + [ + [5, 5, 5], + [1, 1, 1], + ], + ]; + const originalPoints: Vector3Tuple[][] = [ + [ + [0, 0, 0], + [1, 1, 1], + ], + ]; + const currentTransforms: PolylinePointTransform[] = [ + { segmentIndex: 0, pointIndex: 0, position: [5, 5, 5] }, + ]; + const delta: Vector3Tuple = [1, 1, 1]; + + const result = applyDeltaToAllPoints( + effectivePoints, + originalPoints, + currentTransforms, + delta + ); + + expect(result).toEqual([ + { segmentIndex: 0, pointIndex: 0, position: [6, 6, 6] }, + { segmentIndex: 0, pointIndex: 1, position: [2, 2, 2] }, + ]); + }); + + it("handles empty points", () => { + const effectivePoints: Vector3Tuple[][] = []; + const originalPoints: Vector3Tuple[][] = []; + const currentTransforms: PolylinePointTransform[] = []; + const delta: Vector3Tuple = [1, 1, 1]; + + const result = applyDeltaToAllPoints( + effectivePoints, + originalPoints, + currentTransforms, + delta + ); + + expect(result).toEqual([]); + }); + + it("handles zero delta", () => { + const effectivePoints: Vector3Tuple[][] = [ + [ + [0, 0, 0], + [1, 1, 1], + ], + ]; + const originalPoints: Vector3Tuple[][] = [ + [ + [0, 0, 0], + [1, 1, 1], + ], + ]; + const currentTransforms: PolylinePointTransform[] = []; + const delta: Vector3Tuple = [0, 0, 0]; + + const result = applyDeltaToAllPoints( + effectivePoints, + originalPoints, + currentTransforms, + delta + ); + + expect(result).toEqual([ + { segmentIndex: 0, pointIndex: 0, position: [0, 0, 0] }, + { segmentIndex: 0, pointIndex: 1, position: [1, 1, 1] }, + ]); + }); + + it("handles negative delta values", () => { + const effectivePoints: Vector3Tuple[][] = [ + [ + [5, 5, 5], + [3, 3, 3], + ], + ]; + const originalPoints: Vector3Tuple[][] = [ + [ + [5, 5, 5], + [3, 3, 3], + ], + ]; + const currentTransforms: PolylinePointTransform[] = []; + const delta: Vector3Tuple = [-1, -1, -1]; + + const result = applyDeltaToAllPoints( + effectivePoints, + originalPoints, + currentTransforms, + delta + ); + + expect(result).toEqual([ + { segmentIndex: 0, pointIndex: 0, position: [4, 4, 4] }, + { segmentIndex: 0, pointIndex: 1, position: [2, 2, 2] }, + ]); + }); +}); + +describe("updateDuplicateVertices", () => { + it("updates all duplicate vertices across segments", () => { + const movedPoint: Vector3Tuple = [1, 1, 1]; + const newPosition: Vector3Tuple = [5, 5, 5]; + const effectivePoints3d: Vector3Tuple[][] = [ + [ + [0, 0, 0], + [1, 1, 1], // This vertex appears in multiple segments + [2, 2, 2], + ], + [ + [1, 1, 1], // Duplicate vertex + [3, 3, 3], + ], + ]; + const currentTransforms: PolylinePointTransform[] = []; + + const result = updateDuplicateVertices( + movedPoint, + newPosition, + effectivePoints3d, + currentTransforms + ); + + expect(result).toEqual([ + { segmentIndex: 0, pointIndex: 1, position: [5, 5, 5] }, + { segmentIndex: 1, pointIndex: 0, position: [5, 5, 5] }, + ]); + }); + + it("updates existing transforms for duplicate vertices", () => { + const movedPoint: Vector3Tuple = [1, 1, 1]; + const newPosition: Vector3Tuple = [5, 5, 5]; + const effectivePoints3d: Vector3Tuple[][] = [ + [ + [0, 0, 0], + [1, 1, 1], + [2, 2, 2], + ], + [ + [1, 1, 1], + [3, 3, 3], + ], + ]; + const currentTransforms: PolylinePointTransform[] = [ + { segmentIndex: 0, pointIndex: 1, position: [2, 2, 2] }, + { segmentIndex: 1, pointIndex: 0, position: [3, 3, 3] }, + ]; + + const result = updateDuplicateVertices( + movedPoint, + newPosition, + effectivePoints3d, + currentTransforms + ); + + expect(result).toEqual([ + { segmentIndex: 0, pointIndex: 1, position: [5, 5, 5] }, + { segmentIndex: 1, pointIndex: 0, position: [5, 5, 5] }, + ]); + }); + + it("handles vertices with no duplicates", () => { + const movedPoint: Vector3Tuple = [1, 1, 1]; + const newPosition: Vector3Tuple = [5, 5, 5]; + const effectivePoints3d: Vector3Tuple[][] = [ + [ + [0, 0, 0], + [1, 1, 1], + [2, 2, 2], + ], + [ + [3, 3, 3], + [4, 4, 4], + ], + ]; + const currentTransforms: PolylinePointTransform[] = []; + + const result = updateDuplicateVertices( + movedPoint, + newPosition, + effectivePoints3d, + currentTransforms + ); + + expect(result).toEqual([ + { segmentIndex: 0, pointIndex: 1, position: [5, 5, 5] }, + ]); + }); + + it("handles multiple segments with the same vertex", () => { + const movedPoint: Vector3Tuple = [1, 1, 1]; + const newPosition: Vector3Tuple = [5, 5, 5]; + const effectivePoints3d: Vector3Tuple[][] = [ + [ + [1, 1, 1], // First occurrence + [2, 2, 2], + ], + [ + [1, 1, 1], // Second occurrence + [3, 3, 3], + ], + [ + [1, 1, 1], // Third occurrence + [4, 4, 4], + ], + ]; + const currentTransforms: PolylinePointTransform[] = []; + + const result = updateDuplicateVertices( + movedPoint, + newPosition, + effectivePoints3d, + currentTransforms + ); + + expect(result).toEqual([ + { segmentIndex: 0, pointIndex: 0, position: [5, 5, 5] }, + { segmentIndex: 1, pointIndex: 0, position: [5, 5, 5] }, + { segmentIndex: 2, pointIndex: 0, position: [5, 5, 5] }, + ]); + }); + + it("preserves existing transforms for non-duplicate vertices", () => { + const movedPoint: Vector3Tuple = [1, 1, 1]; + const newPosition: Vector3Tuple = [5, 5, 5]; + const effectivePoints3d: Vector3Tuple[][] = [ + [ + [0, 0, 0], + [1, 1, 1], + [2, 2, 2], + ], + [ + [1, 1, 1], + [3, 3, 3], + ], + ]; + const currentTransforms: PolylinePointTransform[] = [ + { segmentIndex: 0, pointIndex: 0, position: [10, 10, 10] }, + { segmentIndex: 1, pointIndex: 1, position: [20, 20, 20] }, + ]; + + const result = updateDuplicateVertices( + movedPoint, + newPosition, + effectivePoints3d, + currentTransforms + ); + + expect(result).toEqual([ + { segmentIndex: 0, pointIndex: 0, position: [10, 10, 10] }, + { segmentIndex: 1, pointIndex: 1, position: [20, 20, 20] }, + { segmentIndex: 0, pointIndex: 1, position: [5, 5, 5] }, + { segmentIndex: 1, pointIndex: 0, position: [5, 5, 5] }, + ]); + }); + + it("handles empty effectivePoints3d array", () => { + const movedPoint: Vector3Tuple = [1, 1, 1]; + const newPosition: Vector3Tuple = [5, 5, 5]; + const effectivePoints3d: Vector3Tuple[][] = []; + const currentTransforms: PolylinePointTransform[] = []; + + const result = updateDuplicateVertices( + movedPoint, + newPosition, + effectivePoints3d, + currentTransforms + ); + + expect(result).toEqual([]); + }); + + it("handles empty segments in effectivePoints3d", () => { + const movedPoint: Vector3Tuple = [1, 1, 1]; + const newPosition: Vector3Tuple = [5, 5, 5]; + const effectivePoints3d: Vector3Tuple[][] = [[], []]; + const currentTransforms: PolylinePointTransform[] = []; + + const result = updateDuplicateVertices( + movedPoint, + newPosition, + effectivePoints3d, + currentTransforms + ); + + expect(result).toEqual([]); + }); + + it("handles floating point precision in vertex matching", () => { + const movedPoint: Vector3Tuple = [1.0, 1.0, 1.0]; + const newPosition: Vector3Tuple = [5, 5, 5]; + const effectivePoints3d: Vector3Tuple[][] = [ + [ + [0, 0, 0], + [1.0, 1.0, 1.0], // Exact match + [2, 2, 2], + ], + [ + [1.0, 1.0, 1.0], // Exact match + [3, 3, 3], + ], + ]; + const currentTransforms: PolylinePointTransform[] = []; + + const result = updateDuplicateVertices( + movedPoint, + newPosition, + effectivePoints3d, + currentTransforms + ); + + expect(result).toEqual([ + { segmentIndex: 0, pointIndex: 1, position: [5, 5, 5] }, + { segmentIndex: 1, pointIndex: 0, position: [5, 5, 5] }, + ]); + }); + + it("handles negative coordinates", () => { + const movedPoint: Vector3Tuple = [-1, -1, -1]; + const newPosition: Vector3Tuple = [5, 5, 5]; + const effectivePoints3d: Vector3Tuple[][] = [ + [ + [0, 0, 0], + [-1, -1, -1], + [2, 2, 2], + ], + [ + [-1, -1, -1], + [3, 3, 3], + ], + ]; + const currentTransforms: PolylinePointTransform[] = []; + + const result = updateDuplicateVertices( + movedPoint, + newPosition, + effectivePoints3d, + currentTransforms + ); + + expect(result).toEqual([ + { segmentIndex: 0, pointIndex: 1, position: [5, 5, 5] }, + { segmentIndex: 1, pointIndex: 0, position: [5, 5, 5] }, + ]); + }); + + it("handles zero coordinates", () => { + const movedPoint: Vector3Tuple = [0, 0, 0]; + const newPosition: Vector3Tuple = [5, 5, 5]; + const effectivePoints3d: Vector3Tuple[][] = [ + [ + [0, 0, 0], + [1, 1, 1], + [2, 2, 2], + ], + [ + [0, 0, 0], + [3, 3, 3], + ], + ]; + const currentTransforms: PolylinePointTransform[] = []; + + const result = updateDuplicateVertices( + movedPoint, + newPosition, + effectivePoints3d, + currentTransforms + ); + + expect(result).toEqual([ + { segmentIndex: 0, pointIndex: 0, position: [5, 5, 5] }, + { segmentIndex: 1, pointIndex: 0, position: [5, 5, 5] }, + ]); + }); + + it("handles large coordinate values", () => { + const movedPoint: Vector3Tuple = [1000000, 2000000, 3000000]; + const newPosition: Vector3Tuple = [5000000, 5000000, 5000000]; + const effectivePoints3d: Vector3Tuple[][] = [ + [ + [0, 0, 0], + [1000000, 2000000, 3000000], + [2, 2, 2], + ], + [ + [1000000, 2000000, 3000000], + [3, 3, 3], + ], + ]; + const currentTransforms: PolylinePointTransform[] = []; + + const result = updateDuplicateVertices( + movedPoint, + newPosition, + effectivePoints3d, + currentTransforms + ); + + expect(result).toEqual([ + { segmentIndex: 0, pointIndex: 1, position: [5000000, 5000000, 5000000] }, + { segmentIndex: 1, pointIndex: 0, position: [5000000, 5000000, 5000000] }, + ]); + }); +}); + +describe("findClosestPointOnSegment", () => { + it("finds closest point at the start of segment", () => { + const segmentStart: Vector3Tuple = [0, 0, 0]; + const segmentEnd: Vector3Tuple = [10, 0, 0]; + const clickPosition: Vector3Tuple = [-5, 0, 0]; + + const result = findClosestPointOnSegment( + segmentStart, + segmentEnd, + clickPosition + ); + + expect(result.closestPoint).toEqual([0, 0, 0]); + expect(result.t).toBe(0); + }); + + it("finds closest point at the end of segment", () => { + const segmentStart: Vector3Tuple = [0, 0, 0]; + const segmentEnd: Vector3Tuple = [10, 0, 0]; + const clickPosition: Vector3Tuple = [15, 0, 0]; + + const result = findClosestPointOnSegment( + segmentStart, + segmentEnd, + clickPosition + ); + + expect(result.closestPoint).toEqual([10, 0, 0]); + expect(result.t).toBe(1); + }); + + it("finds closest point in the middle of segment", () => { + const segmentStart: Vector3Tuple = [0, 0, 0]; + const segmentEnd: Vector3Tuple = [10, 0, 0]; + const clickPosition: Vector3Tuple = [5, 5, 0]; + + const result = findClosestPointOnSegment( + segmentStart, + segmentEnd, + clickPosition + ); + + expect(result.closestPoint).toEqual([5, 0, 0]); + expect(result.t).toBeCloseTo(0.5); + }); + + it("handles diagonal segments", () => { + const segmentStart: Vector3Tuple = [0, 0, 0]; + const segmentEnd: Vector3Tuple = [10, 10, 10]; + const clickPosition: Vector3Tuple = [5, 5, 0]; + + const result = findClosestPointOnSegment( + segmentStart, + segmentEnd, + clickPosition + ); + + // The closest point should be somewhere along the diagonal + expect(result.t).toBeGreaterThan(0); + expect(result.t).toBeLessThan(1); + }); + + it("handles zero-length segments", () => { + const segmentStart: Vector3Tuple = [5, 5, 5]; + const segmentEnd: Vector3Tuple = [5, 5, 5]; + const clickPosition: Vector3Tuple = [10, 10, 10]; + + const result = findClosestPointOnSegment( + segmentStart, + segmentEnd, + clickPosition + ); + + expect(result.closestPoint).toEqual([5, 5, 5]); + expect(result.t).toBe(0); + }); + + it("projects perpendicular clicks correctly", () => { + const segmentStart: Vector3Tuple = [0, 0, 0]; + const segmentEnd: Vector3Tuple = [10, 0, 0]; + const clickPosition: Vector3Tuple = [5, 100, 0]; + + const result = findClosestPointOnSegment( + segmentStart, + segmentEnd, + clickPosition + ); + + // Should project straight down to [5, 0, 0] + expect(result.closestPoint[0]).toBeCloseTo(5); + expect(result.closestPoint[1]).toBeCloseTo(0); + expect(result.closestPoint[2]).toBeCloseTo(0); + expect(result.t).toBeCloseTo(0.5); + }); +}); + +describe("insertVertexInSegment", () => { + it("splits a simple two-point segment correctly", () => { + const originalPoints: Vector3Tuple[][] = [ + [ + [0, 0, 0], + [10, 0, 0], + ], + ]; + const currentTransforms: PolylinePointTransform[] = []; + const newVertexPosition: Vector3Tuple = [5, 0, 0]; + + const result = insertVertexInSegment( + originalPoints, + currentTransforms, + 0, + newVertexPosition + ); + + expect(result).not.toBeNull(); + + // Should create transforms for segment 0 (A->B) and segment 1 (B->C) + expect(result!).toHaveLength(3); + + // Segment 0: point 1 should be the new vertex + expect( + result!.find((t) => t.segmentIndex === 0 && t.pointIndex === 1)?.position + ).toEqual([5, 0, 0]); + + // Segment 1: point 0 should be the new vertex + expect( + result!.find((t) => t.segmentIndex === 1 && t.pointIndex === 0)?.position + ).toEqual([5, 0, 0]); + + // Segment 1: point 1 should be the original end point + expect( + result!.find((t) => t.segmentIndex === 1 && t.pointIndex === 1)?.position + ).toEqual([10, 0, 0]); + }); + + it("increments subsequent segment indices", () => { + const originalPoints: Vector3Tuple[][] = [ + [ + [0, 0, 0], + [10, 0, 0], + ], + [ + [10, 0, 0], + [20, 0, 0], + ], + [ + [20, 0, 0], + [30, 0, 0], + ], + ]; + const currentTransforms: PolylinePointTransform[] = [ + { segmentIndex: 1, pointIndex: 0, position: [12, 2, 0] }, + { segmentIndex: 2, pointIndex: 1, position: [32, 2, 0] }, + ]; + const newVertexPosition: Vector3Tuple = [5, 0, 0]; + + const result = insertVertexInSegment( + originalPoints, + currentTransforms, + 0, + newVertexPosition + ); + + expect(result).not.toBeNull(); + + // Original segment 1 should now be segment 2 + const segmentTwoTransforms = result!.filter((t) => t.segmentIndex === 2); + expect(segmentTwoTransforms.length).toBeGreaterThan(0); + expect( + segmentTwoTransforms.find((t) => t.pointIndex === 0)?.position + ).toEqual([12, 2, 0]); + + // Original segment 2 should now be segment 3 + const segmentThreeTransforms = result!.filter((t) => t.segmentIndex === 3); + expect(segmentThreeTransforms.length).toBeGreaterThan(0); + expect( + segmentThreeTransforms.find((t) => t.pointIndex === 1)?.position + ).toEqual([32, 2, 0]); + }); + + it("preserves transforms on the target segment's first point", () => { + const originalPoints: Vector3Tuple[][] = [ + [ + [0, 0, 0], + [10, 0, 0], + ], + ]; + const currentTransforms: PolylinePointTransform[] = [ + { segmentIndex: 0, pointIndex: 0, position: [1, 1, 1] }, + { segmentIndex: 0, pointIndex: 1, position: [11, 1, 1] }, + ]; + const newVertexPosition: Vector3Tuple = [5, 0, 0]; + + const result = insertVertexInSegment( + originalPoints, + currentTransforms, + 0, + newVertexPosition + ); + + expect(result).not.toBeNull(); + + // First point of segment 0 should keep its transform + expect( + result!.find((t) => t.segmentIndex === 0 && t.pointIndex === 0)?.position + ).toEqual([1, 1, 1]); + }); + + it("throws error for invalid segment index", () => { + const originalPoints: Vector3Tuple[][] = [ + [ + [0, 0, 0], + [10, 0, 0], + ], + ]; + const currentTransforms: PolylinePointTransform[] = []; + const newVertexPosition: Vector3Tuple = [5, 0, 0]; + + expect(() => + insertVertexInSegment( + originalPoints, + currentTransforms, + 5, + newVertexPosition + ) + ).toThrow("Invalid segment index"); + }); + + it("throws error for segment with less than 2 points", () => { + const originalPoints: Vector3Tuple[][] = [[[0, 0, 0]]]; + const currentTransforms: PolylinePointTransform[] = []; + const newVertexPosition: Vector3Tuple = [5, 0, 0]; + + expect(() => + insertVertexInSegment( + originalPoints, + currentTransforms, + 0, + newVertexPosition + ) + ).toThrow("must have at least 2 points"); + }); + + it("handles multi-point segments (more than 2 points)", () => { + const originalPoints: Vector3Tuple[][] = [ + [ + [0, 0, 0], + [5, 0, 0], + [10, 0, 0], + ], + ]; + const currentTransforms: PolylinePointTransform[] = []; + const newVertexPosition: Vector3Tuple = [2, 0, 0]; + + const result = insertVertexInSegment( + originalPoints, + currentTransforms, + 0, + newVertexPosition + ); + + expect(result).not.toBeNull(); + + // Segment 0 should have 2 points: [0,0,0] and [2,0,0] + expect(result!.filter((t) => t.segmentIndex === 0).length).toBe(1); // Only new vertex + + // Segment 1 should have 3 points: [2,0,0], [5,0,0], [10,0,0] + const segment1Transforms = result!.filter((t) => t.segmentIndex === 1); + expect(segment1Transforms.length).toBe(3); + }); + + it("handles splitting middle segment in multi-segment polyline", () => { + const originalPoints: Vector3Tuple[][] = [ + [ + [0, 0, 0], + [10, 0, 0], + ], + [ + [10, 0, 0], + [20, 0, 0], + ], + [ + [20, 0, 0], + [30, 0, 0], + ], + ]; + const currentTransforms: PolylinePointTransform[] = []; + const newVertexPosition: Vector3Tuple = [15, 0, 0]; + + const result = insertVertexInSegment( + originalPoints, + currentTransforms, + 1, + newVertexPosition + ); + + expect(result).not.toBeNull(); + + // Segment 0 should remain unchanged (no transforms) + expect(result!.filter((t) => t.segmentIndex === 0).length).toBe(0); + + // Segment 1 should have new vertex at point 1 + expect( + result!.find((t) => t.segmentIndex === 1 && t.pointIndex === 1)?.position + ).toEqual([15, 0, 0]); + + // Segment 2 should have new vertex at point 0 and original end point at point 1 + expect( + result!.find((t) => t.segmentIndex === 2 && t.pointIndex === 0)?.position + ).toEqual([15, 0, 0]); + expect( + result!.find((t) => t.segmentIndex === 2 && t.pointIndex === 1)?.position + ).toEqual([20, 0, 0]); + + // Original segment 2 is now at index 3, but since it had no transforms, + // it still won't have any (it uses original points) + // Verify the total transform count: segment 1 (1 transform) + segment 2 (2 transforms) = 3 + expect(result!.length).toBe(3); + }); + + it("returns null when new vertex is too close to existing vertex (at start)", () => { + const originalPoints: Vector3Tuple[][] = [ + [ + [0, 0, 0], + [10, 0, 0], + ], + ]; + const currentTransforms: PolylinePointTransform[] = []; + // New vertex is exactly at the start point + const newVertexPosition: Vector3Tuple = [0, 0, 0]; + + const result = insertVertexInSegment( + originalPoints, + currentTransforms, + 0, + newVertexPosition + ); + + expect(result).toBeNull(); + }); + + it("returns null when new vertex is too close to existing vertex (at end)", () => { + const originalPoints: Vector3Tuple[][] = [ + [ + [0, 0, 0], + [10, 0, 0], + ], + ]; + const currentTransforms: PolylinePointTransform[] = []; + // New vertex is exactly at the end point + const newVertexPosition: Vector3Tuple = [10, 0, 0]; + + const result = insertVertexInSegment( + originalPoints, + currentTransforms, + 0, + newVertexPosition + ); + + expect(result).toBeNull(); + }); + + it("returns null when new vertex is within epsilon tolerance of existing vertex", () => { + const originalPoints: Vector3Tuple[][] = [ + [ + [0, 0, 0], + [10, 0, 0], + ], + ]; + const currentTransforms: PolylinePointTransform[] = []; + // New vertex is very close to start point (within epsilon) + const newVertexPosition: Vector3Tuple = [0.0000001, 0.0000001, 0.0000001]; + + const result = insertVertexInSegment( + originalPoints, + currentTransforms, + 0, + newVertexPosition + ); + + expect(result).toBeNull(); + }); + + it("inserts vertex when new position is outside epsilon tolerance", () => { + const originalPoints: Vector3Tuple[][] = [ + [ + [0, 0, 0], + [10, 0, 0], + ], + ]; + const currentTransforms: PolylinePointTransform[] = []; + // New vertex is outside epsilon tolerance (default is 1e-6) + const newVertexPosition: Vector3Tuple = [0.001, 0, 0]; + + const result = insertVertexInSegment( + originalPoints, + currentTransforms, + 0, + newVertexPosition + ); + + expect(result).not.toBeNull(); + expect(result!.length).toBeGreaterThan(0); + }); + + it("returns null when new vertex is close to any vertex in multi-point segment", () => { + const originalPoints: Vector3Tuple[][] = [ + [ + [0, 0, 0], + [5, 0, 0], + [10, 0, 0], + ], + ]; + const currentTransforms: PolylinePointTransform[] = []; + // New vertex is very close to the middle point + const newVertexPosition: Vector3Tuple = [5, 0.0000001, 0]; + + const result = insertVertexInSegment( + originalPoints, + currentTransforms, + 0, + newVertexPosition + ); + + expect(result).toBeNull(); + }); + + it("returns null when new vertex is close to a transformed vertex", () => { + const originalPoints: Vector3Tuple[][] = [ + [ + [0, 0, 0], + [10, 0, 0], + ], + ]; + // The end point has been transformed to [12, 2, 0] + const currentTransforms: PolylinePointTransform[] = [ + { segmentIndex: 0, pointIndex: 1, position: [12, 2, 0] }, + ]; + // New vertex is very close to the transformed position + const newVertexPosition: Vector3Tuple = [12, 2.0000001, 0]; + + const result = insertVertexInSegment( + originalPoints, + currentTransforms, + 0, + newVertexPosition + ); + + expect(result).toBeNull(); + }); +}); + +describe("findClickedSegment", () => { + it("finds segment when click is close to it", () => { + const effectivePoints: Vector3Tuple[][] = [ + [ + [0, 0, 0], + [10, 0, 0], + ], + ]; + const clickPosition: Vector3Tuple = [5, 0.05, 0]; + + const result = findClickedSegment(effectivePoints, clickPosition, 0.1); + + expect(result).not.toBeNull(); + expect(result?.segmentIndex).toBe(0); + expect(result?.newVertexPosition[0]).toBeCloseTo(5); + expect(result?.newVertexPosition[1]).toBeCloseTo(0); + }); + + it("returns null when click is too far from any segment", () => { + const effectivePoints: Vector3Tuple[][] = [ + [ + [0, 0, 0], + [10, 0, 0], + ], + ]; + const clickPosition: Vector3Tuple = [5, 10, 0]; + + const result = findClickedSegment(effectivePoints, clickPosition, 0.1); + + expect(result).toBeNull(); + }); + + it("finds closest segment in multi-segment polyline", () => { + const effectivePoints: Vector3Tuple[][] = [ + [ + [0, 0, 0], + [10, 0, 0], + ], + [ + [10, 0, 0], + [20, 10, 0], + ], + [ + [20, 10, 0], + [30, 10, 0], + ], + ]; + const clickPosition: Vector3Tuple = [25, 10.05, 0]; + + const result = findClickedSegment(effectivePoints, clickPosition, 0.2); + + expect(result).not.toBeNull(); + expect(result?.segmentIndex).toBe(2); + }); + + it("respects custom maxDistance parameter", () => { + const effectivePoints: Vector3Tuple[][] = [ + [ + [0, 0, 0], + [10, 0, 0], + ], + ]; + const clickPosition: Vector3Tuple = [5, 0.5, 0]; + + // With default tolerance (0.1), should not find + const result1 = findClickedSegment(effectivePoints, clickPosition, 0.1); + expect(result1).toBeNull(); + + // With larger tolerance (1.0), should find + const result2 = findClickedSegment(effectivePoints, clickPosition, 1.0); + expect(result2).not.toBeNull(); + expect(result2?.segmentIndex).toBe(0); + }); + + it("handles segments with multiple points", () => { + const effectivePoints: Vector3Tuple[][] = [ + [ + [0, 0, 0], + [5, 0, 0], + [10, 0, 0], + ], + ]; + const clickPosition: Vector3Tuple = [7, 0.05, 0]; + + const result = findClickedSegment(effectivePoints, clickPosition, 0.2); + + expect(result).not.toBeNull(); + expect(result?.segmentIndex).toBe(0); + // Click is between [5,0,0] and [10,0,0] + expect(result?.newVertexPosition[0]).toBeCloseTo(7); + }); + + it("returns null for empty segments", () => { + const effectivePoints: Vector3Tuple[][] = []; + const clickPosition: Vector3Tuple = [5, 0, 0]; + + const result = findClickedSegment(effectivePoints, clickPosition); + + expect(result).toBeNull(); + }); + + it("handles segments with only one point", () => { + const effectivePoints: Vector3Tuple[][] = [[[0, 0, 0]]]; + const clickPosition: Vector3Tuple = [0, 0, 0]; + + const result = findClickedSegment(effectivePoints, clickPosition); + + // Single point segments should be skipped + expect(result).toBeNull(); + }); +}); diff --git a/app/packages/looker-3d/src/annotation/utils/polyline-utils.ts b/app/packages/looker-3d/src/annotation/utils/polyline-utils.ts new file mode 100644 index 00000000000..619ccddb29a --- /dev/null +++ b/app/packages/looker-3d/src/annotation/utils/polyline-utils.ts @@ -0,0 +1,536 @@ +import * as THREE from "three"; +import type { Vector3Tuple } from "three"; +import { SNAP_TOLERANCE } from "../../constants"; +import type { PolylinePointTransform } from "../types"; + +// Epsilon tolerance for floating-point position comparisons +const EPS = 1e-6; + +/** + * Compares two 3D positions using epsilon tolerance for floating-point precision. + * + * @param p1 - First position + * @param p2 - Second position + * @param epsilon - Tolerance for comparison (default: EPS) + * @returns True if positions are equal within tolerance + */ +function positionsEqual( + p1: [number, number, number], + p2: [number, number, number], + epsilon: number = EPS +): boolean { + return ( + Math.abs(p1[0] - p2[0]) <= epsilon && + Math.abs(p1[1] - p2[1]) <= epsilon && + Math.abs(p1[2] - p2[2]) <= epsilon + ); +} + +/** + * Gets the current position of a vertex, considering any applied transforms. + * + * @param selectedPoint - The point to look up with segmentIndex, pointIndex, and labelId + * @param polylinePoints3d - Original polyline points array + * @param transforms - Array of applied transforms + * @returns The current position of the vertex or null if not found + */ +export function getVertexPosition( + selectedPoint: { segmentIndex: number; pointIndex: number; labelId: string }, + polylinePoints3d: Vector3Tuple[][], + transforms: PolylinePointTransform[] +): [number, number, number] | null { + if (!selectedPoint || !polylinePoints3d) return null; + + const { segmentIndex, pointIndex } = selectedPoint; + + // Check if segment exists + if (segmentIndex >= polylinePoints3d.length) return null; + + // Check if point exists in segment + if (pointIndex >= polylinePoints3d[segmentIndex].length) return null; + + // Look for existing transform + const transform = transforms.find( + (t) => t.segmentIndex === segmentIndex && t.pointIndex === pointIndex + ); + + // Return transformed position if exists, otherwise return original position + if (transform) { + return transform.position; + } + + return polylinePoints3d[segmentIndex][pointIndex]; +} + +/** + * Calculates the centroid (center of mass) of a polyline from its points. + * + * @param points3d - Array of polyline segments, each containing 3D points + * @returns The centroid as [x, y, z] coordinates + */ +export function calculatePolylineCentroid( + points3d: Vector3Tuple[][] +): [number, number, number] { + if (!points3d || points3d.length === 0) return [0, 0, 0]; + + // Flatten all points from all segments + const allPoints = points3d.flat(); + + if (allPoints.length === 0) return [0, 0, 0]; + + // Calculate sum of all coordinates + const sum = allPoints.reduce( + (acc, point) => [acc[0] + point[0], acc[1] + point[1], acc[2] + point[2]], + [0, 0, 0] + ); + + // Return average coordinates + return [ + sum[0] / allPoints.length, + sum[1] / allPoints.length, + sum[2] / allPoints.length, + ]; +} + +/** + * Finds all segments that share a specific point position. + * + * @param transforms - Array of point transforms + * @param position - The position to search for + * @returns Array of segment indices that contain this position + */ +export function findSharedPointSegments( + transforms: PolylinePointTransform[], + position: [number, number, number] +): number[] { + const segmentsWithThisPoint = transforms.filter((transform) => + positionsEqual(transform.position, position) + ); + + // Get unique segment indices + const segmentIndices = new Set( + segmentsWithThisPoint.map((transform) => transform.segmentIndex) + ); + + return Array.from(segmentIndices); +} + +/** + * Determines if a polyline should be closed based on proximity to the first vertex. + * + * @param vertices - Array of current vertices in the polyline + * @param currentPosition - The current mouse/cursor position + * @param snapTolerance - Distance threshold for snapping (default: SNAP_TOLERANCE) + * @returns True if the current position is close enough to the first vertex to close the loop + */ +export function shouldClosePolylineLoop( + vertices: Vector3Tuple[], + currentPosition: Vector3Tuple, + snapTolerance: number = SNAP_TOLERANCE +): boolean { + if (vertices.length < 3) return false; + + const firstVertex = new THREE.Vector3(...vertices[0]); + const currentPos = new THREE.Vector3(...currentPosition); + + return currentPos.distanceTo(firstVertex) < snapTolerance; +} + +/** + * Gets the current effective position of a vertex, considering transforms. + * + * @param segmentIndex - Index of the segment + * @param pointIndex - Index of the point within the segment + * @param originalPoints - Original polyline points + * @param transforms - Applied transforms + * @returns The current effective position of the vertex + */ +export function getCurrentVertexPosition( + segmentIndex: number, + pointIndex: number, + originalPoints: Vector3Tuple[][], + transforms: PolylinePointTransform[] +): [number, number, number] { + // Look for existing transform + const transform = transforms.find( + (t) => t.segmentIndex === segmentIndex && t.pointIndex === pointIndex + ); + + if (transform) { + return transform.position; + } + + // Fall back to original position + if ( + segmentIndex < originalPoints.length && + pointIndex < originalPoints[segmentIndex].length + ) { + return originalPoints[segmentIndex][pointIndex]; + } + + // Return default position for invalid indices + return [0, 0, 0]; +} + +/** + * Applies transforms to polyline points, returning the effective points. + * + * @param originalPoints - Original polyline points array + * @param transforms - Array of transforms to apply + * @returns Array of effective points after applying transforms + */ +export function applyTransformsToPolyline( + originalPoints: Vector3Tuple[][], + transforms: PolylinePointTransform[] +): Vector3Tuple[][] { + // Start with a copy of original points + const result = originalPoints.map((segment) => [...segment]); + + // Apply each transform + transforms.forEach((transform) => { + const { segmentIndex, pointIndex, position } = transform; + + // Ensure segment exists + if (result[segmentIndex] === undefined) { + result[segmentIndex] = [ + [0, 0, 0], + [0, 0, 0], + ]; + } + + // Apply transform if point exists + if (pointIndex < result[segmentIndex].length) { + result[segmentIndex][pointIndex] = position; + } + }); + + return result; +} + +/** + * Applies a delta transformation to all points in a polyline. + * + * @param effectivePoints - Current effective points + * @param originalPoints - Original polyline points + * @param currentTransforms - Existing transforms + * @param delta - The delta to apply to all points + * @returns New transforms array with delta applied to all points + */ +export function applyDeltaToAllPoints( + effectivePoints: Vector3Tuple[][], + originalPoints: Vector3Tuple[][], + currentTransforms: PolylinePointTransform[], + delta: Vector3Tuple +): PolylinePointTransform[] { + const newTransforms: PolylinePointTransform[] = []; + + // Helper to get current effective point for (segmentIndex, pointIndex) + const getCurrent = ( + segmentIndex: number, + pointIndex: number + ): [number, number, number] => { + const t = currentTransforms.find( + (x) => x.segmentIndex === segmentIndex && x.pointIndex === pointIndex + ); + return (t?.position ?? originalPoints[segmentIndex][pointIndex]) as [ + number, + number, + number + ]; + }; + + // Apply delta to all points + effectivePoints.forEach((segment, segmentIndex) => { + segment.forEach((_, pointIndex) => { + const base = getCurrent(segmentIndex, pointIndex); + const p = new THREE.Vector3(...base).add(new THREE.Vector3(...delta)); + newTransforms.push({ + segmentIndex, + pointIndex, + position: [p.x, p.y, p.z], + }); + }); + }); + + return newTransforms; +} + +/** + * Updates all duplicate vertices across segments when a vertex is moved. + * This prevents tearing by ensuring all segments sharing a vertex move together. + * + * @param movedPoint - The original point that was moved + * @param newPosition - The new position to apply to all duplicates + * @param effectivePoints3d - Current effective points array + * @param currentTransforms - Existing transforms + * @returns Updated transforms array with new position applied to all duplicate vertices + */ +export function updateDuplicateVertices( + movedPoint: Vector3Tuple, + newPosition: Vector3Tuple, + effectivePoints3d: Vector3Tuple[][], + currentTransforms: PolylinePointTransform[] +): PolylinePointTransform[] { + // Find all occurrences of this vertex across all segments + const duplicates: { segmentIndex: number; pointIndex: number }[] = []; + + effectivePoints3d.forEach((segmentPoints, segIdx) => { + segmentPoints.forEach((candidatePoint, ptIdx) => { + if (positionsEqual(candidatePoint, movedPoint)) { + duplicates.push({ + segmentIndex: segIdx, + pointIndex: ptIdx, + }); + } + }); + }); + + let newTransforms = [...currentTransforms]; + + // Apply the new position to all duplicate vertices + duplicates.forEach(({ segmentIndex: dupSeg, pointIndex: dupPt }) => { + // Check if this vertex already has a transform + const existingTransformIndex = newTransforms.findIndex( + (transform) => + transform.segmentIndex === dupSeg && transform.pointIndex === dupPt + ); + + // Create the new transform with the updated position + const newTransform = { + segmentIndex: dupSeg, + pointIndex: dupPt, + position: newPosition, + }; + + // Update existing transform or add new one + if (existingTransformIndex >= 0) { + newTransforms[existingTransformIndex] = newTransform; + } else { + newTransforms.push(newTransform); + } + }); + + return newTransforms; +} + +/** + * Finds the closest point on a line segment to a given click position. + * + * @param segmentStart - Start point of the segment + * @param segmentEnd - End point of the segment + * @param clickPosition - The position where the user clicked + * @returns The closest point on the segment and the parametric t value (0 to 1) + */ +export function findClosestPointOnSegment( + segmentStart: Vector3Tuple, + segmentEnd: Vector3Tuple, + clickPosition: Vector3Tuple +): { closestPoint: Vector3Tuple; t: number } { + const start = new THREE.Vector3(...segmentStart); + const end = new THREE.Vector3(...segmentEnd); + const click = new THREE.Vector3(...clickPosition); + + const segmentVector = new THREE.Vector3().subVectors(end, start); + const segmentLength = segmentVector.length(); + + // If segment has zero length, return the start point + if (segmentLength < EPS) { + return { closestPoint: segmentStart, t: 0 }; + } + + const clickVector = new THREE.Vector3().subVectors(click, start); + + // Project click onto segment vector + const t = clickVector.dot(segmentVector) / (segmentLength * segmentLength); + + // Clamp t to [0, 1] to stay on the segment + const clampedT = Math.max(0, Math.min(1, t)); + + const closestPoint = new THREE.Vector3() + .copy(start) + .add(segmentVector.multiplyScalar(clampedT)); + + return { + closestPoint: [closestPoint.x, closestPoint.y, closestPoint.z], + t: clampedT, + }; +} + +/** + * Inserts a new vertex into a polyline segment, splitting it into two segments. + * All subsequent segments have their indices incremented. + * + * @param originalPoints - Original polyline points array + * @param currentTransforms - Current transforms applied to the polyline + * @param targetSegmentIndex - Index of the segment to split + * @param newVertexPosition - Position of the new vertex to insert + * @returns New transforms array with the inserted vertex and renumbered segments, or null if the new vertex is too close to existing vertices + */ +export function insertVertexInSegment( + originalPoints: Vector3Tuple[][], + currentTransforms: PolylinePointTransform[], + targetSegmentIndex: number, + newVertexPosition: Vector3Tuple +): PolylinePointTransform[] | null { + // Validate segment index + if (targetSegmentIndex < 0 || targetSegmentIndex >= originalPoints.length) { + throw new Error(`Invalid segment index: ${targetSegmentIndex}`); + } + + const targetSegment = originalPoints[targetSegmentIndex]; + if (targetSegment.length < 2) { + throw new Error( + `Segment ${targetSegmentIndex} must have at least 2 points` + ); + } + + // Get effective points for the target segment + const effectiveSegment = targetSegment.map((point, pointIndex) => { + const transform = currentTransforms.find( + (t) => + t.segmentIndex === targetSegmentIndex && t.pointIndex === pointIndex + ); + return transform ? transform.position : point; + }); + + // Check if the new vertex is too close to any existing vertex in the segment + // If so, we should not insert it to avoid redundant vertices + for (const existingVertex of effectiveSegment) { + if (positionsEqual(newVertexPosition, existingVertex)) { + // New vertex is too close to an existing vertex, skip insertion + return null; + } + } + + // Step 1: Increment all segment indices >= targetSegmentIndex + 1 + const transformsWithUpdatedIndices = currentTransforms.map((transform) => { + if (transform.segmentIndex > targetSegmentIndex) { + return { + ...transform, + segmentIndex: transform.segmentIndex + 1, + }; + } + return transform; + }); + + // Step 2: Split the target segment + // Assume we are inserting a new vertex B between points A and C in segment N + // Original segment N: A -> C becomes: + // New segment N: A -> B (new vertex) + // New segment N+1: B (new vertex) -> C + + // For segment N (keeping first point, adding new vertex as second point) + const segmentNTransforms: PolylinePointTransform[] = [ + // Keep the first point of original segment as is (point 0) + ...transformsWithUpdatedIndices.filter( + (t) => t.segmentIndex === targetSegmentIndex && t.pointIndex === 0 + ), + // Add new vertex as point 1 of segment N + { + segmentIndex: targetSegmentIndex, + pointIndex: 1, + position: newVertexPosition, + }, + ]; + + // For segment N+1 (new vertex as first point, rest of original segment as subsequent points) + const segmentNPlus1Transforms: PolylinePointTransform[] = [ + // New vertex as point 0 of segment N+1 + { + segmentIndex: targetSegmentIndex + 1, + pointIndex: 0, + position: newVertexPosition, + }, + ]; + + // Add the rest of the original segment's points (from point 1 onwards) to segment N+1 + // but shifted: original point 1 becomes point 1, point 2 becomes point 2, etc. + for (let i = 1; i < effectiveSegment.length; i++) { + const originalTransform = transformsWithUpdatedIndices.find( + (t) => t.segmentIndex === targetSegmentIndex && t.pointIndex === i + ); + + // If there was a transform for this point, keep it but with adjusted indices + if (originalTransform) { + segmentNPlus1Transforms.push({ + segmentIndex: targetSegmentIndex + 1, + pointIndex: i, + position: originalTransform.position, + }); + } else { + // Otherwise, create a new transform from the effective point + segmentNPlus1Transforms.push({ + segmentIndex: targetSegmentIndex + 1, + pointIndex: i, + position: effectiveSegment[i], + }); + } + } + + // Step 3: Combine all transforms + // - Remove old transforms for the target segment + // - Add new transforms for segments N and N+1 + const finalTransforms = [ + ...transformsWithUpdatedIndices.filter( + (t) => t.segmentIndex !== targetSegmentIndex + ), + ...segmentNTransforms, + ...segmentNPlus1Transforms, + ]; + + return finalTransforms; +} + +/** + * Finds which segment was clicked based on the click position and effective points. + * + * @param effectivePoints - Current effective points of the polyline + * @param clickPosition - Position where user clicked + * @param maxDistance - Maximum distance to consider a click on a segment (default: 0.1) + * @returns Segment index and the new vertex position, or null if no segment was close enough + */ +export function findClickedSegment( + effectivePoints: Vector3Tuple[][], + clickPosition: Vector3Tuple, + maxDistance: number = 0.1 +): { segmentIndex: number; newVertexPosition: Vector3Tuple } | null { + let closestSegmentIndex = -1; + let closestDistance = Infinity; + let closestPoint: Vector3Tuple | null = null; + + effectivePoints.forEach((segment, segmentIndex) => { + if (segment.length < 2) return; + + // Check each line in the segment (between consecutive points) + for (let i = 0; i < segment.length - 1; i++) { + const start = segment[i]; + const end = segment[i + 1]; + + const { closestPoint: pointOnSegment } = findClosestPointOnSegment( + start, + end, + clickPosition + ); + + const distance = new THREE.Vector3(...pointOnSegment).distanceTo( + new THREE.Vector3(...clickPosition) + ); + + if (distance < closestDistance) { + closestDistance = distance; + closestSegmentIndex = segmentIndex; + closestPoint = pointOnSegment; + } + } + }); + + // Check if the closest distance is within the threshold + if (closestDistance <= maxDistance && closestPoint !== null) { + return { + segmentIndex: closestSegmentIndex, + newVertexPosition: closestPoint, + }; + } + + return null; +} diff --git a/app/packages/looker-3d/src/assets/icons/magnet.svg b/app/packages/looker-3d/src/assets/icons/magnet.svg new file mode 100644 index 00000000000..034d6681887 --- /dev/null +++ b/app/packages/looker-3d/src/assets/icons/magnet.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/packages/looker-3d/src/fo3d/components/HoveredPointMarker.tsx b/app/packages/looker-3d/src/components/HoveredPointMarker.tsx similarity index 100% rename from app/packages/looker-3d/src/fo3d/components/HoveredPointMarker.tsx rename to app/packages/looker-3d/src/components/HoveredPointMarker.tsx diff --git a/app/packages/looker-3d/src/constants.ts b/app/packages/looker-3d/src/constants.ts index 306d34e5fe1..fdb75bc0f16 100644 --- a/app/packages/looker-3d/src/constants.ts +++ b/app/packages/looker-3d/src/constants.ts @@ -1,5 +1,5 @@ import { ColorscaleInput } from "@fiftyone/looker/src/state"; -import { Vector3 } from "three"; +import { Box3, Vector3 } from "three"; import type { ShadeBy } from "./types"; export const ACTION_GRID = "grid"; @@ -23,6 +23,14 @@ export const SHADE_BY_NONE = "none"; export const DEFAULT_CAMERA_POSITION = () => new Vector3(0, 5, -5); +// Default bounding box when scene bounds cannot be determined +export const DEFAULT_BOUNDING_BOX = new Box3( + // min + new Vector3(-5, -5, -5), + // max + new Vector3(5, 5, 5) +); + export const ACTIONS = [ { label: "Color By", value: ACTION_SHADE_BY }, { label: "Set Point Size", value: ACTION_SET_POINT_SIZE }, @@ -82,6 +90,9 @@ export const LABEL_3D_HOVERED_AND_SELECTED_COLOR = "#de7e5d"; export const LABEL_3D_HOVERED_COLOR = "#f2f0ef"; export const LABEL_3D_INSTANCE_HOVERED_COLOR = "#ffffff"; export const LABEL_3D_SIMILAR_SELECTED_COLOR = "#ffa500"; +export const LABEL_3D_SELECTED_FOR_ANNOTATION_COLOR = "orangered"; +export const LABEL_3D_ANNOTATION_POINT_SELECTED_FOR_TRANSFORMATION_COLOR = + "#ff0000"; // ray casting for points export const RAY_CASTING_SENSITIVITY = { @@ -97,3 +108,7 @@ export const RAY_CASTING_SENSITIVITY = { // Useful for sparse clouds, large points, or user-friendly selection modes. low: 0.25, }; + +export const SNAP_TOLERANCE = 0.5; + +export const SCENE_BOUNDS_EXPANSION_FACTOR = 5; diff --git a/app/packages/looker-3d/src/fo3d/Fo3dCanvas.tsx b/app/packages/looker-3d/src/fo3d/Fo3dCanvas.tsx new file mode 100644 index 00000000000..238575dab5f --- /dev/null +++ b/app/packages/looker-3d/src/fo3d/Fo3dCanvas.tsx @@ -0,0 +1,167 @@ +import * as fos from "@fiftyone/state"; +import { + AdaptiveDpr, + AdaptiveEvents, + Bvh, + CameraControls, + OrbitControls, + PerspectiveCamera as PerspectiveCameraDrei, +} from "@react-three/drei"; +import { useAtomValue } from "jotai"; +import * as THREE from "three"; +import { Vector3 } from "three"; +import { SpinningCube } from "../SpinningCube"; +import { StatusTunnel } from "../StatusBar"; +import { AnnotationPlane } from "../annotation/AnnotationPlane"; +import { SegmentPolylineRenderer } from "../annotation/SegmentPolylineRenderer"; +import { ThreeDLabels } from "../labels"; +import { FoSceneComponent } from "./FoScene"; +import { Gizmos } from "./Gizmos"; +import { SceneControls } from "./scene-controls/SceneControls"; + +interface Fo3dSceneContentProps { + /** + * Camera position + */ + cameraPosition: Vector3; + /** + * Up vector for the camera + */ + upVector: Vector3 | null; + /** + * Camera field of view + */ + fov?: number; + /** + * Camera near plane + */ + near?: number; + /** + * Camera far plane + */ + far?: number; + /** + * Camera aspect ratio + */ + aspect?: number; + /** + * Camera zoom (for orthographic) + */ + zoom?: number; + /** + * Whether auto-rotate is enabled + */ + autoRotate: boolean; + /** + * Reference to camera controls + */ + cameraControlsRef: React.RefObject; + /** + * The 3D scene to render + */ + foScene: any; + /** + * Whether the scene is initialized + */ + isSceneInitialized: boolean; + /** + * Sample data for labels + */ + sample: any; + /** + * Point cloud settings + */ + pointCloudSettings: any; + /** + * Reference to the assets group + */ + assetsGroupRef: React.RefObject; + /** + * Reference to camera + */ + cameraRef?: React.RefObject; + /** + * Whether or not to render gizmo helper. + */ + isGizmoHelperVisible?: boolean; +} + +export const Fo3dSceneContent = ({ + cameraPosition, + upVector, + fov = 50, + near = 0.1, + far = 2500, + aspect = 1, + zoom = 100, + autoRotate, + cameraControlsRef, + foScene, + isSceneInitialized, + isGizmoHelperVisible, + sample, + pointCloudSettings, + assetsGroupRef, + cameraRef, +}: Fo3dSceneContentProps) => { + const mode = useAtomValue(fos.modalMode); + + return ( + <> + + + + + } + position={cameraPosition} + up={upVector ?? [0, 1, 0]} + fov={foScene?.cameraProps.fov ?? fov} + near={foScene?.cameraProps.near ?? near} + far={foScene?.cameraProps.far ?? far} + aspect={foScene?.cameraProps.aspect ?? aspect} + onUpdate={(cam) => cam.updateProjectionMatrix()} + /> + + {!autoRotate ? ( + + ) : ( + + )} + + + + + {!isSceneInitialized && } + + + + + + + {isSceneInitialized && ( + + )} + + {mode === "annotate" && } + + ); +}; + +const AnnotationControls = () => { + return ( + <> + + + + ); +}; diff --git a/app/packages/looker-3d/src/fo3d/Gizmos.tsx b/app/packages/looker-3d/src/fo3d/Gizmos.tsx index 75bb486ced8..ff8cc1f7118 100644 --- a/app/packages/looker-3d/src/fo3d/Gizmos.tsx +++ b/app/packages/looker-3d/src/fo3d/Gizmos.tsx @@ -80,7 +80,13 @@ const FoAxesHelper = ({ return <>{axisComponents}; }; -export const Gizmos = () => { +export const Gizmos = ({ + isGizmoHelperVisible, + isGridVisible, +}: { + isGizmoHelperVisible: boolean; + isGridVisible: boolean; +}) => { const { upVector, sceneBoundingBox } = useFo3dContext(); const isGridOn = useRecoilValue(isGridOnAtom); @@ -146,7 +152,7 @@ export const Gizmos = () => { return ( <> - {isGridOn && ( + {isGridOn && isGridVisible && ( <> { )} - - - + {isGizmoHelperVisible && ( + + + + )} ); }; diff --git a/app/packages/looker-3d/src/fo3d/MediaTypeFo3d.tsx b/app/packages/looker-3d/src/fo3d/MediaTypeFo3d.tsx index 1540beecd1a..4c75109c69a 100644 --- a/app/packages/looker-3d/src/fo3d/MediaTypeFo3d.tsx +++ b/app/packages/looker-3d/src/fo3d/MediaTypeFo3d.tsx @@ -1,17 +1,11 @@ import { LoadingDots } from "@fiftyone/components"; import { usePluginSettings } from "@fiftyone/plugins"; import * as fos from "@fiftyone/state"; -import { useBrowserStorage } from "@fiftyone/state"; -import { - AdaptiveDpr, - AdaptiveEvents, - Bvh, - CameraControls, - OrbitControls, - PerspectiveCamera as PerspectiveCameraDrei, -} from "@react-three/drei"; +import { isInMultiPanelViewAtom, useBrowserStorage } from "@fiftyone/state"; +import { CameraControls } from "@react-three/drei"; import { Canvas } from "@react-three/fiber"; import CameraControlsImpl from "camera-controls"; +import { useAtomValue } from "jotai"; import { useCallback, useEffect, @@ -20,37 +14,42 @@ import { useRef, useState, } from "react"; -import { useRecoilCallback, useRecoilValue } from "recoil"; +import { useRecoilCallback, useRecoilValue, useSetRecoilState } from "recoil"; +import styled from "styled-components"; import * as THREE from "three"; import { Vector3 } from "three"; -import { CAMERA_POSITION_KEY } from "../Environment"; -import { SpinningCube } from "../SpinningCube"; -import { StatusBar, StatusTunnel } from "../StatusBar"; +import { StatusBar } from "../StatusBar"; +import { MultiPanelView } from "../annotation/MultiPanelView"; +import { AnnotationToolbar } from "../annotation/annotation-toolbar/AnnotationToolbar"; import { PcdColorMapTunnel } from "../components/PcdColormapModal"; import { + DEFAULT_BOUNDING_BOX, DEFAULT_CAMERA_POSITION, - RAY_CASTING_SENSITIVITY, SET_EGO_VIEW_EVENT, SET_TOP_VIEW_EVENT, } from "../constants"; import { StatusBarRootContainer } from "../containers"; import { useFo3d, useHotkey, useTrackStatus } from "../hooks"; import { useFo3dBounds } from "../hooks/use-bounds"; -import { ThreeDLabels } from "../labels"; import type { Looker3dSettings } from "../settings"; import { activeNodeAtom, + annotationPlaneAtom, cameraPositionAtom, + clearTransformStateSelector, currentHoveredPointAtom, + isActivelySegmentingSelector, + isCurrentlyTransformingAtom, isFo3dBackgroundOnAtom, + isSegmentingPointerDownAtom, + selectedPolylineVertexAtom, } from "../state"; import { HoverMetadata } from "../types"; -import { FoSceneComponent } from "./FoScene"; -import { Gizmos } from "./Gizmos"; +import { Fo3dSceneContent } from "./Fo3dCanvas"; import HoverMetadataHUD from "./HoverMetadataHUD"; import { Fo3dSceneContext } from "./context"; -import { SceneControls } from "./scene-controls/SceneControls"; import { + getCameraPositionKey, getFo3dRoot, getMediaPathForFo3dSample, getOrthonormalAxis, @@ -58,6 +57,13 @@ import { const CANVAS_WRAPPER_ID = "sample3d-canvas-wrapper"; +const MainContainer = styled.main` + display: flex; + flex-direction: column; + height: 100%; + width: 100%; +`; + const calculateCameraPositionForUpVector = ( center: Vector3, size: Vector3, @@ -119,6 +125,9 @@ const calculateCameraPositionForUpVector = ( export const MediaTypeFo3dComponent = () => { const sample = useRecoilValue(fos.fo3dSample); const mediaField = useRecoilValue(fos.selectedMediaField(true)); + const is2DSampleViewerVisible = useRecoilValue(fos.groupMediaIsMainVisible); + const isGroup = useRecoilValue(fos.isGroup); + const setIsInMultiPanelView = useSetRecoilState(isInMultiPanelViewAtom); const settings = usePluginSettings("3d"); @@ -127,6 +136,8 @@ export const MediaTypeFo3dComponent = () => { [mediaField, sample] ); + const mode = useAtomValue(fos.modalMode); + const mediaUrl = useMemo(() => fos.getSampleSrc(mediaPath), [mediaPath]); const fo3dRoot = useMemo(() => getFo3dRoot(sample.sample.filepath), [sample]); @@ -209,6 +220,9 @@ export const MediaTypeFo3dComponent = () => { } ); + // todo: reconcile with lookAt from foScene, too + const [lookAt, setLookAt] = useState(null); + useEffect(() => { if (!foScene || upVector) { return; @@ -217,8 +231,13 @@ export const MediaTypeFo3dComponent = () => { setUpVectorVal(getDefaultUpVector()); }, [foScene, upVector, getDefaultUpVector]); + const containerRef = useRef(null); const cameraRef = useRef(); const cameraControlsRef = useRef(); + const datasetName = useRecoilValue(fos.datasetName); + const isActivelySegmenting = useRecoilValue(isActivelySegmentingSelector); + const isSegmentingPointerDown = useRecoilValue(isSegmentingPointerDownAtom); + const isCurrentlyTransforming = useRecoilValue(isCurrentlyTransformingAtom); const keyState = useRef({ shiftRight: false, @@ -228,6 +247,17 @@ export const MediaTypeFo3dComponent = () => { }); const updateCameraControlsConfig = useCallback(() => { + if (!cameraControlsRef.current) return; + + // Disable camera controls when transforming + if (isSegmentingPointerDown || isCurrentlyTransforming) { + cameraControlsRef.current.enabled = false; + return; + } + + // Re-enable camera controls when not transforming + cameraControlsRef.current.enabled = true; + if (keyState.current.shiftRight || keyState.current.shiftLeft) { cameraControlsRef.current.mouseButtons.left = CameraControlsImpl.ACTION.TRUCK; @@ -238,7 +268,14 @@ export const MediaTypeFo3dComponent = () => { cameraControlsRef.current.mouseButtons.left = CameraControlsImpl.ACTION.ROTATE; } - }, [keyState]); + }, [keyState, isCurrentlyTransforming, isSegmentingPointerDown]); + + /** + * This effect updates the camera controls config when the transforming state changes + */ + useEffect(() => { + updateCameraControlsConfig(); + }, [updateCameraControlsConfig]); fos.useEventHandler(document, "keydown", (e: KeyboardEvent) => { if (e.code === "ShiftRight") keyState.current.shiftRight = true; @@ -257,18 +294,32 @@ export const MediaTypeFo3dComponent = () => { }); const assetsGroupRef = useRef(); - const sceneBoundingBox = useFo3dBounds(assetsGroupRef); + + const { + boundingBox: sceneBoundingBox, + recomputeBounds, + isComputing: isComputingSceneBoundingBox, + } = useFo3dBounds(assetsGroupRef); + + const effectiveSceneBoundingBox = sceneBoundingBox || DEFAULT_BOUNDING_BOX; + + useEffect(() => { + if (sceneBoundingBox && !lookAt) { + const center = effectiveSceneBoundingBox.getCenter(new Vector3()); + setLookAt(center); + } + }, [sceneBoundingBox, lookAt, effectiveSceneBoundingBox]); const topCameraPosition = useMemo(() => { if ( !sceneBoundingBox || - Math.abs(sceneBoundingBox.max.x) === Number.POSITIVE_INFINITY + Math.abs(effectiveSceneBoundingBox.max.x) === Number.POSITIVE_INFINITY ) { return DEFAULT_CAMERA_POSITION(); } - const center = sceneBoundingBox.getCenter(new Vector3()); - const size = sceneBoundingBox.getSize(new Vector3()); + const center = effectiveSceneBoundingBox.getCenter(new Vector3()); + const size = effectiveSceneBoundingBox.getSize(new Vector3()); return calculateCameraPositionForUpVector( center, @@ -277,16 +328,17 @@ export const MediaTypeFo3dComponent = () => { 2.5, "top" ); - }, [sceneBoundingBox, upVector]); + }, [sceneBoundingBox, upVector, effectiveSceneBoundingBox]); const overriddenCameraPosition = useRecoilValue(cameraPositionAtom); const lastSavedCameraPosition = useMemo(() => { - const lastSavedCameraPosition = - window?.localStorage.getItem(CAMERA_POSITION_KEY); + const lastSavedCameraPosition = window?.localStorage.getItem( + getCameraPositionKey(datasetName) + ); return lastSavedCameraPosition ? JSON.parse(lastSavedCameraPosition) : null; - }, []); + }, [datasetName]); const getDefaultCameraPosition = useCallback( (ignoreLastSavedCameraPosition = false) => { @@ -344,9 +396,9 @@ export const MediaTypeFo3dComponent = () => { if ( sceneBoundingBox && - Math.abs(sceneBoundingBox.max.x) !== Number.POSITIVE_INFINITY + Math.abs(effectiveSceneBoundingBox.max.x) !== Number.POSITIVE_INFINITY ) { - const size = sceneBoundingBox.getSize(new Vector3()); + const size = effectiveSceneBoundingBox.getSize(new Vector3()); return calculateCameraPositionForUpVector( new Vector3(0, 0, 0), @@ -365,6 +417,7 @@ export const MediaTypeFo3dComponent = () => { isParsingFo3d, foScene, sceneBoundingBox, + effectiveSceneBoundingBox, upVector, lastSavedCameraPosition, ] @@ -377,12 +430,24 @@ export const MediaTypeFo3dComponent = () => { const resetActiveNode = useRecoilCallback( ({ set }) => - () => { + (event: MouseEvent | null) => { + // Don't handle right click since that might mean we're panning the camera + if (event?.type === "contextmenu") { + return; + } + + if (isActivelySegmenting) { + return; + } + set(activeNodeAtom, null); set(currentHoveredPointAtom, null); + set(clearTransformStateSelector, null); + set(selectedPolylineVertexAtom, null); + set(isCurrentlyTransformingAtom, false); setAutoRotate(false); }, - [] + [isActivelySegmenting] ); useLayoutEffect(() => { @@ -394,7 +459,7 @@ export const MediaTypeFo3dComponent = () => { }, [isSceneInitialized]); useEffect(() => { - resetActiveNode(); + resetActiveNode(null); }, [isSceneInitialized, resetActiveNode]); const onChangeView = useCallback( @@ -441,7 +506,7 @@ export const MediaTypeFo3dComponent = () => { ]; // for top view, we have look at at center of bounding box - const center = sceneBoundingBox.getCenter(new Vector3()); + const center = effectiveSceneBoundingBox.getCenter(new Vector3()); newLookAt = [center.x, center.y, center.z] as const; } @@ -458,6 +523,7 @@ export const MediaTypeFo3dComponent = () => { }, [ sceneBoundingBox, + effectiveSceneBoundingBox, topCameraPosition, getDefaultCameraPosition, setSceneInitialized, @@ -608,16 +674,21 @@ export const MediaTypeFo3dComponent = () => { } ); - // this effect runs after the scene is initialized - // and sets the appropriate lookAt and camera position + // this effect sets the appropriate lookAt and camera position + // and marks the scene as initialized useEffect(() => { - if (!cameraControlsRef.current || !cameraRef.current) { + if ( + !cameraControlsRef.current || + !cameraRef.current || + isComputingSceneBoundingBox + ) { return; } // restore camera position and target from localStorage if it exists - const lastSavedCameraState = - window?.localStorage.getItem(CAMERA_POSITION_KEY); + const lastSavedCameraState = window?.localStorage.getItem( + getCameraPositionKey(datasetName) + ); let restored = false; if (lastSavedCameraState) { try { @@ -655,14 +726,21 @@ export const MediaTypeFo3dComponent = () => { setSceneInitialized(true); return; } else { - onChangeView("pov", { + onChangeView("top", { useAnimation: false, ignoreLastSavedCameraPosition: false, isFirstTime: true, }); + setSceneInitialized(true); } } - }, [foScene, onChangeView, cameraControlsRef, cameraRef]); + }, [ + foScene, + onChangeView, + cameraControlsRef, + cameraRef, + isComputingSceneBoundingBox, + ]); useTrackStatus(); @@ -687,6 +765,26 @@ export const MediaTypeFo3dComponent = () => { null ); + const isAnnotationPlaneEnabled = useRecoilValue(annotationPlaneAtom).enabled; + + const shouldRenderMultiPanelView = useMemo( + () => + mode === "annotate" && + !(isGroup && is2DSampleViewerVisible) && + isSceneInitialized, + [mode, isGroup, is2DSampleViewerVisible, isSceneInitialized] + ); + + useEffect(() => { + if (shouldRenderMultiPanelView) { + recomputeBounds(); + } + }, [shouldRenderMultiPanelView, isAnnotationPlaneEnabled, recomputeBounds]); + + useEffect(() => { + setIsInMultiPanelView(shouldRenderMultiPanelView); + }, [shouldRenderMultiPanelView]); + if (isParsingFo3d) { return ; } @@ -698,8 +796,11 @@ export const MediaTypeFo3dComponent = () => { numPrimaryAssets, upVector, setUpVector, + isComputingSceneBoundingBox, fo3dRoot, - sceneBoundingBox, + sceneBoundingBox: effectiveSceneBoundingBox, + lookAt, + setLookAt, autoRotate, setAutoRotate, pointCloudSettings, @@ -709,55 +810,50 @@ export const MediaTypeFo3dComponent = () => { pluginSettings: settings, }} > - - - - - cam.updateProjectionMatrix()} + {shouldRenderMultiPanelView ? ( + - - - {!autoRotate && } - {autoRotate && } - - - - {!isSceneInitialized && } - - - - - - - - {isSceneInitialized && } - - - - + ) : ( + + + + + + + + + + + )} + {mode === "annotate" && } ); }; diff --git a/app/packages/looker-3d/src/fo3d/context.tsx b/app/packages/looker-3d/src/fo3d/context.tsx index 3e213dd3f6a..03a54423582 100644 --- a/app/packages/looker-3d/src/fo3d/context.tsx +++ b/app/packages/looker-3d/src/fo3d/context.tsx @@ -13,7 +13,10 @@ interface Fo3dContextT { numPrimaryAssets: number; upVector: Vector3 | null; setUpVector: (upVector: Vector3) => void; + isComputingSceneBoundingBox: boolean; sceneBoundingBox: Box3 | null; + lookAt: Vector3 | null; + setLookAt: (lookAt: Vector3) => void; pluginSettings: Looker3dSettings | null; fo3dRoot: string | null; autoRotate: boolean; @@ -29,7 +32,10 @@ const defaultContext: Fo3dContextT = { numPrimaryAssets: 0, upVector: null, setUpVector: () => {}, + isComputingSceneBoundingBox: false, sceneBoundingBox: null, + lookAt: null, + setLookAt: () => {}, pluginSettings: null, fo3dRoot: null, autoRotate: false, diff --git a/app/packages/looker-3d/src/fo3d/mesh/Fbx.tsx b/app/packages/looker-3d/src/fo3d/mesh/Fbx.tsx index 8fb8d90dd5f..a45b3d15284 100644 --- a/app/packages/looker-3d/src/fo3d/mesh/Fbx.tsx +++ b/app/packages/looker-3d/src/fo3d/mesh/Fbx.tsx @@ -1,15 +1,16 @@ -import { getSampleSrc } from "@fiftyone/state"; -import { useLoader } from "@react-three/fiber"; +import { getSampleSrc, isInMultiPanelViewAtom } from "@fiftyone/state"; import { useMemo } from "react"; +import { useRecoilValue } from "recoil"; import { AnimationMixer, type Quaternion, type Vector3 } from "three"; import { FBXLoader } from "three-stdlib"; import type { FbxAsset } from "../../hooks"; import { useAnimationSelect } from "../../hooks/use-animation-select"; +import { useFoLoader } from "../../hooks/use-fo-loaders"; import { useMeshMaterialControls } from "../../hooks/use-mesh-material-controls"; import { usePercolateMaterial } from "../../hooks/use-set-scene-transparency"; import { useFo3dContext } from "../context"; import { getBasePathForTextures, getResolvedUrlForFo3dAsset } from "../utils"; -import { useFoLoader } from "../../hooks/use-fo-loaders"; +import { SkeletonUtils } from "three-stdlib"; export const Fbx = ({ name, @@ -27,6 +28,7 @@ export const Fbx = ({ children: React.ReactNode; }) => { const { fo3dRoot } = useFo3dContext(); + const isInMultiPanelView = useRecoilValue(isInMultiPanelViewAtom); const fbxUrl = useMemo( () => @@ -42,10 +44,20 @@ export const Fbx = ({ const { material } = useMeshMaterialControls(name, defaultMaterial, true); - const fbx = useFoLoader(FBXLoader, fbxUrl, (loader) => { + const fbx_ = useFoLoader(FBXLoader, fbxUrl, (loader) => { loader.setResourcePath(resourcePath); }); + // Deep clone mesh when in multipanel view to avoid React Three Fiber caching issues + // todo: optimize this with instanced mesh + const fbx = useMemo(() => { + if (isInMultiPanelView && fbx_) { + // Use SkeletonUtils otherwise skeletons might de-bind + return SkeletonUtils.clone(fbx_); + } + return fbx_; + }, [fbx_, isInMultiPanelView]); + usePercolateMaterial(fbx, material); const animationClips = useMemo(() => { diff --git a/app/packages/looker-3d/src/fo3d/mesh/Gltf.tsx b/app/packages/looker-3d/src/fo3d/mesh/Gltf.tsx index 8386f873e46..332563bf3e6 100644 --- a/app/packages/looker-3d/src/fo3d/mesh/Gltf.tsx +++ b/app/packages/looker-3d/src/fo3d/mesh/Gltf.tsx @@ -1,7 +1,9 @@ -import { getSampleSrc } from "@fiftyone/state"; +import { getSampleSrc, isInMultiPanelViewAtom } from "@fiftyone/state"; import { useGLTF } from "@react-three/drei"; -import { useEffect, useMemo, useRef } from "react"; -import { AnimationMixer, Mesh, type Quaternion, type Vector3 } from "three"; +import { useMemo, useRef } from "react"; +import { useRecoilValue } from "recoil"; +import { AnimationMixer, type Quaternion, type Vector3 } from "three"; +import { SkeletonUtils } from "three-stdlib"; import type { GltfAsset } from "../../hooks"; import { useAnimationSelect } from "../../hooks/use-animation-select"; import { useMeshMaterialControls } from "../../hooks/use-mesh-material-controls"; @@ -25,6 +27,7 @@ export const Gltf = ({ children: React.ReactNode; }) => { const { fo3dRoot } = useFo3dContext(); + const isInMultiPanelView = useRecoilValue(isInMultiPanelViewAtom); const gltfUrl = useMemo( () => @@ -40,9 +43,24 @@ export const Gltf = ({ const { material } = useMeshMaterialControls(name, defaultMaterial, true); - const { scene, animations } = useGLTF(gltfUrl, true, undefined, (loader) => { - loader.setResourcePath(resourcePath); - }); + const { scene: scene_, animations } = useGLTF( + gltfUrl, + true, + undefined, + (loader) => { + loader.setResourcePath(resourcePath); + } + ); + + // Deep clone scene when in multipanel view to avoid React Three Fiber caching issues + // todo: optimize this with instanced mesh + const scene = useMemo(() => { + if (isInMultiPanelView && scene_) { + // Use SkeletonUtils otherwise skeletons might de-bind + return SkeletonUtils.clone(scene_); + } + return scene_; + }, [scene_, isInMultiPanelView]); usePercolateMaterial(scene, material); diff --git a/app/packages/looker-3d/src/fo3d/mesh/Obj.tsx b/app/packages/looker-3d/src/fo3d/mesh/Obj.tsx index 2373973b4fd..1ed5c84b999 100644 --- a/app/packages/looker-3d/src/fo3d/mesh/Obj.tsx +++ b/app/packages/looker-3d/src/fo3d/mesh/Obj.tsx @@ -1,6 +1,6 @@ -import { getSampleSrc } from "@fiftyone/state"; -import { useLoader } from "@react-three/fiber"; +import { getSampleSrc, isInMultiPanelViewAtom } from "@fiftyone/state"; import { useEffect, useMemo } from "react"; +import { useRecoilValue } from "recoil"; import { Mesh, MeshStandardMaterial, @@ -10,11 +10,11 @@ import { import { MTLLoader } from "three/examples/jsm/loaders/MTLLoader"; import { OBJLoader } from "three/examples/jsm/loaders/OBJLoader"; import type { ObjAsset } from "../../hooks"; +import { useFoLoader } from "../../hooks/use-fo-loaders"; import { useMeshMaterialControls } from "../../hooks/use-mesh-material-controls"; import { getColorFromPoolBasedOnHash } from "../../utils"; import { useFo3dContext } from "../context"; import { getBasePathForTextures, getResolvedUrlForFo3dAsset } from "../utils"; -import { useFoLoader } from "../../hooks/use-fo-loaders"; const ObjMeshDefaultMaterial = ({ name, @@ -28,6 +28,7 @@ const ObjMeshDefaultMaterial = ({ const { objPath, preTransformedObjPath } = obj; const { fo3dRoot } = useFo3dContext(); + const isInMultiPanelView = useRecoilValue(isInMultiPanelViewAtom); const objUrl = useMemo( () => @@ -36,7 +37,16 @@ const ObjMeshDefaultMaterial = ({ [objPath, preTransformedObjPath, fo3dRoot] ); - const mesh = useFoLoader(OBJLoader, objUrl); + const mesh_ = useFoLoader(OBJLoader, objUrl); + + // Deep clone mesh when in multipanel view to avoid React Three Fiber caching issues + // todo: optimize this with instanced mesh + const mesh = useMemo(() => { + if (isInMultiPanelView && mesh_) { + return mesh_.clone(true); + } + return mesh_; + }, [mesh_, isInMultiPanelView]); const { material } = useMeshMaterialControls(name, obj.defaultMaterial); @@ -60,10 +70,15 @@ const ObjMeshDefaultMaterial = ({ onLoad?.(); }, [mesh, objUrl, material, onLoad]); + if (!mesh) { + return null; + } + return ; }; const ObjMeshWithCustomMaterial = ({ + name, obj, onLoad, }: { @@ -75,6 +90,7 @@ const ObjMeshWithCustomMaterial = ({ obj; const { fo3dRoot } = useFo3dContext(); + const isInMultiPanelView = useRecoilValue(isInMultiPanelViewAtom); const objUrl = useMemo( () => @@ -100,19 +116,32 @@ const ObjMeshWithCustomMaterial = ({ loader.setResourcePath(resourcePath); } }); - const mesh = useFoLoader(OBJLoader, objUrl, (loader) => { + const mesh_ = useFoLoader(OBJLoader, objUrl, (loader) => { if (mtlUrl) { materials.preload(); loader.setMaterials(materials); } }); + // Deep clone mesh when in multipanel view to avoid React Three Fiber caching issues + // todo: optimize this with instanced mesh + const mesh = useMemo(() => { + if (isInMultiPanelView && mesh_) { + return mesh_.clone(true); + } + return mesh_; + }, [mesh_, isInMultiPanelView]); + useEffect(() => { if (mesh) { onLoad?.(); } }, [mesh, onLoad]); + if (!mesh) { + return null; + } + return ; }; diff --git a/app/packages/looker-3d/src/fo3d/mesh/Ply.tsx b/app/packages/looker-3d/src/fo3d/mesh/Ply.tsx index 323071505ed..b36206ef51a 100644 --- a/app/packages/looker-3d/src/fo3d/mesh/Ply.tsx +++ b/app/packages/looker-3d/src/fo3d/mesh/Ply.tsx @@ -1,5 +1,6 @@ -import { getSampleSrc } from "@fiftyone/state"; +import { getSampleSrc, isInMultiPanelViewAtom } from "@fiftyone/state"; import { useEffect, useMemo, useRef, useState } from "react"; +import { useRecoilValue } from "recoil"; import { type BufferGeometry, Mesh, @@ -8,6 +9,7 @@ import { Vector3, } from "three"; import { PLYLoader } from "three/examples/jsm/loaders/PLYLoader"; +import { HoveredPointMarker } from "../../components/HoveredPointMarker"; import type { FoMeshBasicMaterialProps, FoMeshMaterial, @@ -17,7 +19,6 @@ import type { import { useFoLoader } from "../../hooks/use-fo-loaders"; import { useMeshMaterialControls } from "../../hooks/use-mesh-material-controls"; import { usePointCloudHover } from "../../hooks/use-point-cloud-hover"; -import { HoveredPointMarker } from "../components/HoveredPointMarker"; import { useFo3dContext } from "../context"; import { usePcdMaterial } from "../point-cloud/use-pcd-material"; import { getBasePathForTextures, getResolvedUrlForFo3dAsset } from "../utils"; @@ -172,6 +173,7 @@ export const Ply = ({ children, }: PlyProps) => { const { fo3dRoot } = useFo3dContext(); + const isInMultiPanelView = useRecoilValue(isInMultiPanelViewAtom); const plyUrl = useMemo( () => @@ -185,10 +187,18 @@ export const Ply = ({ [fo3dRoot, plyUrl] ); - const geometry = useFoLoader(PLYLoader, plyUrl, (loader) => { + const geometry_ = useFoLoader(PLYLoader, plyUrl, (loader) => { loader.resourcePath = resourcePath; }); + // Clone geometry when in multipanel view to avoid React Three Fiber caching issues + const geometry = useMemo(() => { + if (isInMultiPanelView && geometry_) { + return geometry_.clone(); + } + return geometry_; + }, [geometry_, isInMultiPanelView]); + const [isUsingVertexColors, setIsUsingVertexColors] = useState(false); const [isGeometryResolved, setIsGeometryResolved] = useState(false); diff --git a/app/packages/looker-3d/src/fo3d/mesh/Stl.tsx b/app/packages/looker-3d/src/fo3d/mesh/Stl.tsx index 10b6e6dde47..fb88ca68ef4 100644 --- a/app/packages/looker-3d/src/fo3d/mesh/Stl.tsx +++ b/app/packages/looker-3d/src/fo3d/mesh/Stl.tsx @@ -1,13 +1,13 @@ -import { getSampleSrc } from "@fiftyone/state"; -import { useLoader } from "@react-three/fiber"; +import { getSampleSrc, isInMultiPanelViewAtom } from "@fiftyone/state"; import { useEffect, useMemo, useState } from "react"; +import { useRecoilValue } from "recoil"; import { Mesh, type Quaternion, type Vector3 } from "three"; import { STLLoader } from "three/examples/jsm/loaders/STLLoader"; import type { StlAsset } from "../../hooks"; +import { useFoLoader } from "../../hooks/use-fo-loaders"; import { useMeshMaterialControls } from "../../hooks/use-mesh-material-controls"; import { useFo3dContext } from "../context"; import { getResolvedUrlForFo3dAsset } from "../utils"; -import { useFoLoader } from "../../hooks/use-fo-loaders"; /** * Renders a single STL mesh. @@ -31,6 +31,7 @@ export const Stl = ({ children?: React.ReactNode; }) => { const { fo3dRoot } = useFo3dContext(); + const isInMultiPanelView = useRecoilValue(isInMultiPanelViewAtom); const stlUrl = useMemo( () => @@ -39,7 +40,16 @@ export const Stl = ({ [stlPath, preTransformedStlPath, fo3dRoot] ); - const points = useFoLoader(STLLoader, stlUrl); + const points_ = useFoLoader(STLLoader, stlUrl); + + // Clone points when in multipanel view to avoid React Three Fiber caching issues + const points = useMemo(() => { + if (isInMultiPanelView && points_) { + return points_.clone(); + } + return points_; + }, [points_, isInMultiPanelView]); + const [mesh, setMesh] = useState(null); const { material } = useMeshMaterialControls(name, defaultMaterial); diff --git a/app/packages/looker-3d/src/fo3d/point-cloud/Pcd.tsx b/app/packages/looker-3d/src/fo3d/point-cloud/Pcd.tsx index cad9bdfc1e5..97aca448a3f 100644 --- a/app/packages/looker-3d/src/fo3d/point-cloud/Pcd.tsx +++ b/app/packages/looker-3d/src/fo3d/point-cloud/Pcd.tsx @@ -9,7 +9,7 @@ import type { PcdAsset } from "../../hooks"; import { useFoLoader } from "../../hooks/use-fo-loaders"; import { usePointCloudHover } from "../../hooks/use-point-cloud-hover"; import { DynamicPCDLoader } from "../../loaders/dynamic-pcd-loader"; -import { HoveredPointMarker } from "../components/HoveredPointMarker"; +import { HoveredPointMarker } from "../../components/HoveredPointMarker"; import { useFo3dContext } from "../context"; import { getResolvedUrlForFo3dAsset } from "../utils"; import { usePcdMaterial } from "./use-pcd-material"; diff --git a/app/packages/looker-3d/src/fo3d/point-cloud/use-pcd-material.tsx b/app/packages/looker-3d/src/fo3d/point-cloud/use-pcd-material.tsx index 61eeee0a953..71dc8fec044 100644 --- a/app/packages/looker-3d/src/fo3d/point-cloud/use-pcd-material.tsx +++ b/app/packages/looker-3d/src/fo3d/point-cloud/use-pcd-material.tsx @@ -1,6 +1,7 @@ import { useCallback, useMemo } from "react"; import type { BufferGeometry, Quaternion } from "three"; import { + DEFAULT_BOUNDING_BOX, SHADE_BY_CUSTOM, SHADE_BY_HEIGHT, SHADE_BY_INTENSITY, @@ -64,7 +65,7 @@ export const usePcdMaterial = ( ) => { const { upVector, pluginSettings } = useFo3dContext(); - const pcdBoundingBox = useFo3dBounds( + const { boundingBox: pcdBoundingBox } = useFo3dBounds( pcdContainerRef, useCallback(() => { return !!geometry; @@ -72,17 +73,18 @@ export const usePcdMaterial = ( ); const minMaxCoordinates = useMemo(() => { - if (!pcdBoundingBox) { - return null; - } + const effectiveBoundingBox = pcdBoundingBox || DEFAULT_BOUNDING_BOX; // note: we should deprecate minZ in the plugin settings, since it doesn't account for non-z up vectors - if (pluginSettings?.pointCloud?.minZ) { - return [pluginSettings.pointCloud.minZ, pcdBoundingBox.max.z]; + if ( + pluginSettings?.pointCloud?.minZ !== null && + pluginSettings?.pointCloud?.minZ !== undefined + ) { + return [pluginSettings.pointCloud.minZ, effectiveBoundingBox.max.z]; } - const min = pcdBoundingBox.min.dot(upVector); - const max = pcdBoundingBox.max.dot(upVector); + const min = effectiveBoundingBox.min.dot(upVector); + const max = effectiveBoundingBox.max.dot(upVector); return [min, max] as const; }, [upVector, pcdBoundingBox, pluginSettings]); diff --git a/app/packages/looker-3d/src/fo3d/scene-controls/SceneControls.tsx b/app/packages/looker-3d/src/fo3d/scene-controls/SceneControls.tsx index c76dada9de2..f179f6e6049 100644 --- a/app/packages/looker-3d/src/fo3d/scene-controls/SceneControls.tsx +++ b/app/packages/looker-3d/src/fo3d/scene-controls/SceneControls.tsx @@ -1,13 +1,15 @@ +import * as fos from "@fiftyone/state"; import { CameraControls } from "@react-three/drei"; import { useFrame } from "@react-three/fiber"; import { folder, useControls } from "leva"; import { useMemo, useRef } from "react"; +import { useRecoilState, useRecoilValue } from "recoil"; import { Vector3 } from "three"; import { PANEL_ORDER_SCENE_CONTROLS } from "../../constants"; -import { CAMERA_POSITION_KEY } from "../../Environment"; import { FoScene } from "../../hooks"; +import { avoidZFightingAtom } from "../../state"; import { useFo3dContext } from "../context"; -import { getOrthonormalAxis } from "../utils"; +import { getCameraPositionKey, getOrthonormalAxis } from "../utils"; import { Lights } from "./lights/Lights"; export const SceneControls = ({ @@ -24,8 +26,13 @@ export const SceneControls = ({ setAutoRotate, pointCloudSettings, setPointCloudSettings, + isComputingSceneBoundingBox, } = useFo3dContext(); + const datasetName = useRecoilValue(fos.datasetName); + const [avoidZFighting, setAvoidZFighting] = + useRecoilState(avoidZFightingAtom); + const dirFromUpVector = useMemo( () => getOrthonormalAxis(upVector), [upVector] @@ -37,6 +44,8 @@ export const SceneControls = ({ const CAMERA_UPDATE_INTERVAL = 250; useFrame((state) => { + if (isComputingSceneBoundingBox) return; + const now = Date.now(); const cameraControls = cameraControlsRef?.current; if ( @@ -49,7 +58,7 @@ export const SceneControls = ({ target: cameraControls.getTarget(new Vector3()).toArray(), }; window?.localStorage.setItem( - CAMERA_POSITION_KEY, + getCameraPositionKey(datasetName), JSON.stringify(cameraState) ); lastCameraUpdateRef.current = now; @@ -80,6 +89,13 @@ export const SceneControls = ({ setAutoRotate(value); }, }, + "Avoid Z fighting": { + value: avoidZFighting, + label: "Avoid Z fighting", + onChange: (value) => { + setAvoidZFighting(value); + }, + }, }, { collapsed: true, order: PANEL_ORDER_SCENE_CONTROLS } ), diff --git a/app/packages/looker-3d/src/fo3d/utils.ts b/app/packages/looker-3d/src/fo3d/utils.ts index 877019a5c08..e3bc82f284a 100644 --- a/app/packages/looker-3d/src/fo3d/utils.ts +++ b/app/packages/looker-3d/src/fo3d/utils.ts @@ -12,6 +12,7 @@ import { Vector3, type Vector3Tuple, } from "three"; +import * as paths from "../../../utilities/src/paths"; import type { FoMeshBasicMaterialProps, FoMeshLambertMaterialProps, @@ -19,7 +20,9 @@ import type { FoScene, FoSceneNode, } from "../hooks"; -import * as paths from "../../../utilities/src/paths"; + +export const getCameraPositionKey = (datasetName?: string) => + `${datasetName ?? "fiftyone"}-fo3d-camera-position`; export const getAssetUrlForSceneNode = (node: FoSceneNode): string => { if (!node.asset) return null; @@ -150,11 +153,13 @@ export const getResolvedUrlForFo3dAsset = ( }; export const getThreeMaterialFromFo3dMaterial = ( - foMtl: Record + foMtl: Record, + avoidZFighting: boolean = true ) => { const { _type, ...props } = foMtl; props["transparent"] = (props.opacity as number) < 1; props["side"] = DoubleSide; + props["depthWrite"] = !avoidZFighting; if (foMtl._type === "MeshBasicMaterial") { return new MeshBasicMaterial(props as FoMeshBasicMaterialProps); diff --git a/app/packages/looker-3d/src/hooks/use-3d-label-color.ts b/app/packages/looker-3d/src/hooks/use-3d-label-color.ts index 3c084dbc5da..4b3ad700931 100644 --- a/app/packages/looker-3d/src/hooks/use-3d-label-color.ts +++ b/app/packages/looker-3d/src/hooks/use-3d-label-color.ts @@ -3,6 +3,7 @@ import { LABEL_3D_HOVERED_AND_SELECTED_COLOR, LABEL_3D_HOVERED_COLOR, LABEL_3D_INSTANCE_HOVERED_COLOR, + LABEL_3D_SELECTED_FOR_ANNOTATION_COLOR, LABEL_3D_SIMILAR_SELECTED_COLOR, } from "../constants"; @@ -11,20 +12,30 @@ export const use3dLabelColor = ({ isHovered, isSimilarLabelHovered, defaultColor, + isSelectedForAnnotation, }: { isSelected: boolean; isHovered: boolean; isSimilarLabelHovered: boolean; defaultColor: string; + isSelectedForAnnotation?: boolean; }) => { return useMemo(() => { const isAnyHovered = isHovered || isSimilarLabelHovered; + if (isSelectedForAnnotation) return LABEL_3D_SELECTED_FOR_ANNOTATION_COLOR; + if (isAnyHovered && isSelected) return LABEL_3D_HOVERED_AND_SELECTED_COLOR; if (isSelected) return LABEL_3D_SIMILAR_SELECTED_COLOR; if (isSimilarLabelHovered) return LABEL_3D_INSTANCE_HOVERED_COLOR; if (isHovered) return LABEL_3D_HOVERED_COLOR; return defaultColor; - }, [isSelected, isHovered, isSimilarLabelHovered, defaultColor]); + }, [ + isSelected, + isHovered, + isSimilarLabelHovered, + defaultColor, + isSelectedForAnnotation, + ]); }; diff --git a/app/packages/looker-3d/src/hooks/use-bounds.test.ts b/app/packages/looker-3d/src/hooks/use-bounds.test.ts index 220cca69c50..a43005a7df4 100644 --- a/app/packages/looker-3d/src/hooks/use-bounds.test.ts +++ b/app/packages/looker-3d/src/hooks/use-bounds.test.ts @@ -8,6 +8,7 @@ vi.useFakeTimers(); vi.mock("three", () => { return { Box3: vi.fn(), + Vector3: vi.fn(), }; }); @@ -17,18 +18,19 @@ describe("useFo3dBounds", () => { vi.resetAllMocks(); }); - it("does not set bounding box when objectRef.current is null", () => { + it("returns null when objectRef.current is null", () => { const objectRef = { current: null } as React.RefObject; const { result } = renderHook(() => useFo3dBounds(objectRef)); - expect(result.current).toBeNull(); + expect(result.current.boundingBox).toBeNull(); act(() => { vi.advanceTimersByTime(500); }); - expect(result.current).toBeNull(); + // The hook should return null when objectRef.current is null + expect(result.current.boundingBox).toBeNull(); }); it("sets bounding box when bounding box stabilizes", () => { @@ -66,7 +68,7 @@ describe("useFo3dBounds", () => { const { result } = renderHook(() => useFo3dBounds(objectRef)); - expect(result.current).toBeNull(); + expect(result.current.boundingBox).toBeNull(); act(() => { for (let i = 0; i < 10; i++) { @@ -74,9 +76,35 @@ describe("useFo3dBounds", () => { } }); - expect(result.current).not.toBeNull(); - expect(result.current.min).toEqual(boxes[1].min); - expect(result.current.max).toEqual(boxes[1].max); + expect(result.current.boundingBox).not.toBeNull(); + expect(result.current.boundingBox.min).toEqual(boxes[1].min); + expect(result.current.boundingBox.max).toEqual(boxes[1].max); + }); + + it("returns null when bounds are incomputable (non-finite box)", () => { + const objectRef = { current: {} } as React.RefObject; + + // Mock Box3 to return a box with non-finite values + const MockBox3 = vi.fn().mockImplementation(() => { + return { + min: { x: Infinity, y: 0, z: 0, equals: vi.fn(() => true) }, + max: { x: 1, y: 1, z: 1, equals: vi.fn(() => true) }, + setFromObject: vi.fn().mockReturnThis(), + }; + }); + + (Box3 as unknown as Mock).mockImplementation(MockBox3); + + const { result } = renderHook(() => useFo3dBounds(objectRef)); + + expect(result.current.boundingBox).toBeNull(); + + act(() => { + vi.advanceTimersByTime(500); + }); + + // The hook should return null when bounds are incomputable + expect(result.current.boundingBox).toBeNull(); }); it("does not proceed if predicate returns false", () => { @@ -85,13 +113,13 @@ describe("useFo3dBounds", () => { const { result } = renderHook(() => useFo3dBounds(objectRef, predicate)); - expect(result.current).toBeNull(); + expect(result.current.boundingBox).toBeNull(); expect(predicate).toHaveBeenCalled(); act(() => { vi.advanceTimersByTime(500); }); - expect(result.current).toBeNull(); + expect(result.current.boundingBox).toBeNull(); }); }); diff --git a/app/packages/looker-3d/src/hooks/use-bounds.ts b/app/packages/looker-3d/src/hooks/use-bounds.ts index c38fbb9b8a2..dc93a143cc8 100644 --- a/app/packages/looker-3d/src/hooks/use-bounds.ts +++ b/app/packages/looker-3d/src/hooks/use-bounds.ts @@ -1,8 +1,24 @@ -import { useLayoutEffect, useRef, useState } from "react"; +import { useCallback, useLayoutEffect, useRef, useState } from "react"; import { Box3, type Group } from "three"; const BOUNDING_BOX_POLLING_INTERVAL = 50; const UNCHANGED_COUNT_THRESHOLD = 6; +const MAX_BOUNDING_BOX_RETRIES = 1000; + +/** + * Checks if a bounding box has all finite values in its min and max components. + * Returns true if all components are finite, false otherwise. + */ +const isFiniteBox = (box: Box3): boolean => { + return ( + Number.isFinite(box.min.x) && + Number.isFinite(box.min.y) && + Number.isFinite(box.min.z) && + Number.isFinite(box.max.x) && + Number.isFinite(box.max.y) && + Number.isFinite(box.max.z) + ); +}; /** * Calculates the bounding box of the object with the given ref. @@ -10,19 +26,42 @@ const UNCHANGED_COUNT_THRESHOLD = 6; * @param objectRef - Ref to the object * @param predicate - Optional predicate to check before calculating the bounding box. * IMPORTANT: Make sure this predicate is memoized using useCallback - * @returns Bounding box of the object + * @returns Object containing the bounding box, a function to recompute it, and a flag indicating if computation is in progress */ export const useFo3dBounds = ( objectRef: React.RefObject, predicate?: () => boolean ) => { - const [sceneBoundingBox, setSceneBoundingBox] = useState(null); + const [boundingBox, setBoundingBox] = useState(null); + const [isComputing, setIsComputing] = useState(false); const unchangedCount = useRef(0); const previousBox = useRef(null); + const retryCount = useRef(0); const timeOutIdRef = useRef(null); + const recomputeBounds = useCallback(() => { + setIsComputing(true); + + if (!objectRef.current) { + setBoundingBox(null); + setIsComputing(false); + return; + } + + const box = new Box3().setFromObject(objectRef.current); + + if (!isFiniteBox(box)) { + setBoundingBox(null); + setIsComputing(false); + return; + } + + setBoundingBox(box); + setIsComputing(false); + }, [objectRef]); + useLayoutEffect(() => { if (predicate && !predicate()) { return; @@ -38,7 +77,18 @@ export const useFo3dBounds = ( const getBoundingBox = () => { if (!isMounted) return; + setIsComputing(true); + if (!objectRef.current) { + retryCount.current += 1; + if (retryCount.current >= MAX_BOUNDING_BOX_RETRIES) { + retryCount.current = 0; + unchangedCount.current = 0; + previousBox.current = null; + setBoundingBox(null); + setIsComputing(false); + return; + } timeOutIdRef.current = window.setTimeout( getBoundingBox, BOUNDING_BOX_POLLING_INTERVAL @@ -48,7 +98,16 @@ export const useFo3dBounds = ( const box = new Box3().setFromObject(objectRef.current); - if (Math.abs(box.max?.x) === Number.POSITIVE_INFINITY) { + if (!isFiniteBox(box)) { + retryCount.current += 1; + if (retryCount.current >= MAX_BOUNDING_BOX_RETRIES) { + retryCount.current = 0; + unchangedCount.current = 0; + previousBox.current = null; + setBoundingBox(null); + setIsComputing(false); + return; + } timeOutIdRef.current = window.setTimeout( getBoundingBox, BOUNDING_BOX_POLLING_INTERVAL @@ -65,7 +124,9 @@ export const useFo3dBounds = ( previousBox.current = box; if (unchangedCount.current >= UNCHANGED_COUNT_THRESHOLD) { - setSceneBoundingBox(box); + retryCount.current = 0; + setBoundingBox(box); + setIsComputing(false); } else { timeOutIdRef.current = window.setTimeout( getBoundingBox, @@ -74,7 +135,7 @@ export const useFo3dBounds = ( } }; - // this is a hack, yet to find a better way than polling to know when the scene is done loading + // this is a hack, yet to find a better way than polling to know when the asset is done loading // callbacks in loaders are not reliable timeOutIdRef.current = window.setTimeout( getBoundingBox, @@ -84,6 +145,8 @@ export const useFo3dBounds = ( // cleanup function to prevent memory leaks return () => { isMounted = false; + retryCount.current = 0; + setIsComputing(false); if (timeOutIdRef.current) { window.clearTimeout(timeOutIdRef.current); @@ -91,5 +154,5 @@ export const useFo3dBounds = ( }; }, [objectRef, predicate]); - return sceneBoundingBox; + return { boundingBox, recomputeBounds, isComputing }; }; diff --git a/app/packages/looker-3d/src/hooks/use-empty-canvas-interaction.ts b/app/packages/looker-3d/src/hooks/use-empty-canvas-interaction.ts new file mode 100644 index 00000000000..010de0901a4 --- /dev/null +++ b/app/packages/looker-3d/src/hooks/use-empty-canvas-interaction.ts @@ -0,0 +1,76 @@ +import { useThree } from "@react-three/fiber"; +import { useEffect, useMemo, useRef } from "react"; +import * as THREE from "three"; +import { + createPlane, + getPlaneIntersection, + isButtonMatch, + toNDC, +} from "../utils"; + +type Options = { + onPointerUp?: (pt: THREE.Vector3, ev: PointerEvent) => void; + onPointerDown?: () => void; + onPointerMove?: (pt: THREE.Vector3, ev: PointerEvent) => void; + planeNormal?: THREE.Vector3; + planeConstant?: number; + button?: number; +}; + +export function useEmptyCanvasInteraction({ + onPointerUp, + onPointerDown, + onPointerMove, + planeNormal = new THREE.Vector3(0, 1, 0), + planeConstant = 0, + button = 0, +}: Options = {}) { + // Refs to avoid re-rendering + const onPointerUpRef = useRef(onPointerUp); + const onPointerDownRef = useRef(onPointerDown); + const onPointerMoveRef = useRef(onPointerMove); + + onPointerUpRef.current = onPointerUp; + onPointerDownRef.current = onPointerDown; + onPointerMoveRef.current = onPointerMove; + + const { gl, camera, scene, raycaster, events } = useThree(); + + const plane = useMemo( + () => createPlane(planeNormal, planeConstant), + [planeNormal, planeConstant] + ); + + useEffect(() => { + const el = (events.connected ?? gl.domElement) as HTMLCanvasElement; + + const handlePointerMove = (ev: PointerEvent) => { + if (!onPointerMoveRef.current) return; + const ndc = toNDC(ev, el); + const pt = getPlaneIntersection(raycaster, camera, ndc, plane); + if (pt) onPointerMoveRef.current(pt, ev); + }; + + const handleDown = (ev: PointerEvent) => { + if (!isButtonMatch(ev, button)) return; + onPointerDownRef.current?.(); + }; + + const handleUp = (ev: PointerEvent) => { + if (!isButtonMatch(ev, button)) return; + const ndc = toNDC(ev, el); + const pt = getPlaneIntersection(raycaster, camera, ndc, plane); + if (pt) onPointerUpRef.current?.(pt, ev); + }; + + el.addEventListener("pointermove", handlePointerMove, { passive: true }); + el.addEventListener("pointerdown", handleDown, { passive: true }); + el.addEventListener("pointerup", handleUp, { passive: true }); + + return () => { + el.removeEventListener("pointermove", handlePointerMove); + el.removeEventListener("pointerdown", handleDown); + el.removeEventListener("pointerup", handleUp); + }; + }, [gl, camera, scene, raycaster, events, plane, button]); +} diff --git a/app/packages/looker-3d/src/hooks/use-mesh-material-controls.ts b/app/packages/looker-3d/src/hooks/use-mesh-material-controls.ts index dfc5466a6fa..a28f5c1c136 100644 --- a/app/packages/looker-3d/src/hooks/use-mesh-material-controls.ts +++ b/app/packages/looker-3d/src/hooks/use-mesh-material-controls.ts @@ -1,7 +1,9 @@ import { folder, useControls } from "leva"; import { useMemo, useState } from "react"; +import { useRecoilValue } from "recoil"; import { PANEL_ORDER_PCD_CONTROLS } from "../constants"; import { getThreeMaterialFromFo3dMaterial } from "../fo3d/utils"; +import { avoidZFightingAtom } from "../state"; import type { FoMeshMaterial } from "./use-fo3d"; export const useMeshMaterialControls = ( @@ -9,6 +11,7 @@ export const useMeshMaterialControls = ( foMeshMaterial: FoMeshMaterial, omitColorControls = false ) => { + const avoidZFighting = useRecoilValue(avoidZFightingAtom); const [opacity, setOpacity] = useState(foMeshMaterial.opacity); const [renderAsWireframe, setRenderAsWireframe] = useState( foMeshMaterial.wireframe @@ -132,16 +135,19 @@ export const useMeshMaterialControls = ( ); const material = useMemo(() => { - return getThreeMaterialFromFo3dMaterial({ - ...foMeshMaterial, - opacity, - wireframe: renderAsWireframe, - color, - metalness, - roughness, - emissiveColor, - emissiveIntensity, - }); + return getThreeMaterialFromFo3dMaterial( + { + ...foMeshMaterial, + opacity, + wireframe: renderAsWireframe, + color, + metalness, + roughness, + emissiveColor, + emissiveIntensity, + }, + avoidZFighting + ); }, [ foMeshMaterial, opacity, @@ -151,6 +157,7 @@ export const useMeshMaterialControls = ( roughness, emissiveColor, emissiveIntensity, + avoidZFighting, ]); return { diff --git a/app/packages/looker-3d/src/labels/cuboid.tsx b/app/packages/looker-3d/src/labels/cuboid.tsx index 9b8f467a4b5..b2792862a48 100644 --- a/app/packages/looker-3d/src/labels/cuboid.tsx +++ b/app/packages/looker-3d/src/labels/cuboid.tsx @@ -1,21 +1,19 @@ -import { useCursor } from "@react-three/drei"; import { extend } from "@react-three/fiber"; -import { useMemo, useState } from "react"; -import { useRecoilValue } from "recoil"; +import { useEffect, useMemo } from "react"; import * as THREE from "three"; import { LineMaterial } from "three/examples/jsm/lines/LineMaterial"; import { LineSegments2 } from "three/examples/jsm/lines/LineSegments2"; import { LineSegmentsGeometry } from "three/examples/jsm/lines/LineSegmentsGeometry"; -import { use3dLabelColor } from "../hooks/use-3d-label-color"; -import { useSimilarLabels3d } from "../hooks/use-similar-labels-3d"; -import { cuboidLabelLineWidthAtom } from "../state"; import type { OverlayProps } from "./shared"; +import { useEventHandlers, useHoverState, useLabelColor } from "./shared/hooks"; + extend({ LineSegments2, LineMaterial, LineSegmentsGeometry }); export interface CuboidProps extends OverlayProps { location: THREE.Vector3Tuple; dimensions: THREE.Vector3Tuple; itemRotation: THREE.Vector3Tuple; + lineWidth?: number; } export const Cuboid = ({ @@ -24,6 +22,7 @@ export const Cuboid = ({ opacity, rotation, location, + lineWidth, selected, onClick, tooltip, @@ -31,7 +30,6 @@ export const Cuboid = ({ color, useLegacyCoordinates, }: CuboidProps) => { - const lineWidth = useRecoilValue(cuboidLabelLineWidthAtom); const geo = useMemo( () => dimensions && new THREE.BoxGeometry(...dimensions), [dimensions] @@ -58,8 +56,18 @@ export const Cuboid = ({ [resolvedRotation, itemRotationVec] ); - const [isCuboidHovered, setIsCuboidHovered] = useState(false); - useCursor(isCuboidHovered); + const { isHovered, setIsHovered } = useHoverState(); + const { onPointerOver, onPointerOut, restEventHandlers } = useEventHandlers( + tooltip, + label + ); + + const { strokeAndFillColor, isSimilarLabelHovered } = useLabelColor( + { selected, color }, + isHovered, + label, + false + ); const edgesGeo = useMemo(() => new THREE.EdgesGeometry(geo), [geo]); const geometry = useMemo( @@ -70,20 +78,11 @@ export const Cuboid = ({ [edgesGeo] ); - const isSimilarLabelHovered = useSimilarLabels3d(label); - - const strokeAndFillColor = use3dLabelColor({ - isSelected: selected, - isHovered: isCuboidHovered, - isSimilarLabelHovered, - defaultColor: color, - }); - const material = useMemo( () => new LineMaterial({ opacity: opacity, - transparent: false, + transparent: opacity < 0.2, color: strokeAndFillColor, linewidth: lineWidth, }), @@ -91,17 +90,21 @@ export const Cuboid = ({ selected, lineWidth, opacity, - isCuboidHovered, + isHovered, isSimilarLabelHovered, strokeAndFillColor, ] ); - const { onPointerOver, onPointerOut, ...restEventHandlers } = useMemo(() => { - return { - ...tooltip.getMeshProps(label), + // Cleanup + useEffect(() => { + return () => { + geo.dispose(); + edgesGeo.dispose(); + geometry.dispose(); + material.dispose(); }; - }, [tooltip, label]); + }, [geo, edgesGeo, geometry, material]); if (!location || !dimensions) return null; @@ -114,20 +117,27 @@ export const Cuboid = ({ */ return ( - - - - + <> + {/* Outline */} + {/* @ts-ignore */} + + + {/* Clickable volume */} { - setIsCuboidHovered(true); + setIsHovered(true); onPointerOver(); }} onPointerOut={() => { - setIsCuboidHovered(false); + setIsHovered(false); onPointerOut(); }} {...restEventHandlers} @@ -139,6 +149,6 @@ export const Cuboid = ({ color={strokeAndFillColor} /> - + ); }; diff --git a/app/packages/looker-3d/src/labels/index.tsx b/app/packages/looker-3d/src/labels/index.tsx index e3347d753f9..75ada4ba7a1 100644 --- a/app/packages/looker-3d/src/labels/index.tsx +++ b/app/packages/looker-3d/src/labels/index.tsx @@ -1,6 +1,6 @@ +import { activeSchemas } from "@fiftyone/core/src/components/Modal/Sidebar/Annotate/state"; import { FO_LABEL_TOGGLED_EVENT, - LabelData, LabelToggledEvent, Sample, selectiveRenderingEventBus, @@ -14,14 +14,27 @@ import * as fos from "@fiftyone/state"; import { fieldSchema } from "@fiftyone/state"; import { useOnShiftClickLabel } from "@fiftyone/state/src/hooks/useOnShiftClickLabel"; import { ThreeEvent } from "@react-three/fiber"; +import { useAtomValue } from "jotai"; import { folder, useControls } from "leva"; import { get as _get } from "lodash"; import { useCallback, useEffect, useMemo } from "react"; -import { useRecoilState, useRecoilValue } from "recoil"; +import { useRecoilState, useRecoilValue, useSetRecoilState } from "recoil"; +import * as THREE from "three"; +import type { PolylinePointTransform } from "../annotation/types"; import { PANEL_ORDER_LABELS } from "../constants"; import { usePathFilter } from "../hooks"; import { type Looker3dSettings, defaultPluginSettings } from "../settings"; -import { cuboidLabelLineWidthAtom, polylineLabelLineWidthAtom } from "../state"; +import { + cuboidLabelLineWidthAtom, + currentArchetypeSelectedForTransformAtom, + editSegmentsModeAtom, + polylineLabelLineWidthAtom, + polylinePointTransformsAtom, + segmentPolylineStateAtom, + selectedLabelForAnnotationAtom, + transformModeAtom, +} from "../state"; +import { TransformArchetype } from "../types"; import { toEulerFromDegreesArray } from "../utils"; import { Cuboid, type CuboidProps } from "./cuboid"; import { type OverlayLabel, load3dOverlays } from "./loader"; @@ -29,12 +42,19 @@ import { type PolyLineProps, Polyline } from "./polyline"; export interface ThreeDLabelsProps { sampleMap: { [sliceOrFilename: string]: Sample } | fos.Sample[]; + globalOpacity?: number; } -export const ThreeDLabels = ({ sampleMap }: ThreeDLabelsProps) => { +export const ThreeDLabels = ({ + sampleMap, + globalOpacity, +}: ThreeDLabelsProps) => { + const mode = useAtomValue(fos.modalMode); const schema = useRecoilValue(fieldSchema({ space: fos.State.SPACE.SAMPLE })); + const annotationSchemas = useAtomValue(activeSchemas); const { coloring, selectedLabelTags, customizeColorSetting, labelTagColors } = useRecoilValue(fos.lookerOptions({ withFilter: true, modal: true })); + const isSegmenting = useRecoilValue(segmentPolylineStateAtom).isActive; const settings = fop.usePluginSettings( "3d", @@ -49,11 +69,21 @@ export const ThreeDLabels = ({ sampleMap }: ThreeDLabelsProps) => { const [polylineWidth, setPolylineWidth] = useRecoilState( polylineLabelLineWidthAtom ); + const polylinePointTransforms = useRecoilValue(polylinePointTransformsAtom); const selectedLabels = useRecoilValue(fos.selectedLabelMap); const tooltip = fos.useTooltip(); - const labelAlpha = colorScheme.opacity; + const labelAlpha = globalOpacity ?? colorScheme.opacity; + + const [selectedLabelForAnnotation, setSelectedLabelForAnnotation] = + useRecoilState(selectedLabelForAnnotationAtom); + const setEditSegmentsMode = useSetRecoilState(editSegmentsModeAtom); + + const [transformMode, setTransformMode] = useRecoilState(transformModeAtom); + const setCurrentArchetypeSelectedForTransform = useSetRecoilState( + currentArchetypeSelectedForTransformAtom + ); - const constLabelLevaControls = { + const labelLevaControls = { cuboidLineWidget: { value: cuboidLineWidth, min: 0, @@ -76,9 +106,11 @@ export const ThreeDLabels = ({ sampleMap }: ThreeDLabelsProps) => { }, }; - const [labelConfig] = useControls( + const currentSampleId = useRecoilValue(fos.currentSampleId); + + useControls( () => ({ - Labels: folder(constLabelLevaControls, { + Labels: folder(labelLevaControls, { order: PANEL_ORDER_LABELS, collapsed: true, }), @@ -86,8 +118,40 @@ export const ThreeDLabels = ({ sampleMap }: ThreeDLabelsProps) => { [setCuboidLineWidth, setPolylineWidth] ); + const selectLabelForAnnotation = useCallback( + (label: OverlayLabel) => { + setSelectedLabelForAnnotation(label); + + // We only support translate for polylines for now + if ( + label._cls === "Polyline" && + (transformMode === "rotate" || transformMode === "scale") + ) { + setTransformMode("translate"); + } + }, + [setSelectedLabelForAnnotation, transformMode, setTransformMode] + ); + const handleSelect = useCallback( - (label: OverlayLabel, e: ThreeEvent) => { + ( + label: OverlayLabel, + archetype: TransformArchetype, + e: ThreeEvent + ) => { + if (isSegmenting) return; + + if (mode === "annotate") { + if (archetype === "cuboid") { + return; + } + + selectLabelForAnnotation(label); + setCurrentArchetypeSelectedForTransform(archetype); + + return; + } + onSelectLabel({ detail: { id: label._id, @@ -98,9 +162,31 @@ export const ThreeDLabels = ({ sampleMap }: ThreeDLabelsProps) => { }, }); }, - [onSelectLabel] + [onSelectLabel, mode, selectLabelForAnnotation, isSegmenting] ); + useEffect(() => { + const handler = (event: KeyboardEvent) => { + if ( + event.key === "Escape" && + mode === "annotate" && + selectedLabelForAnnotation + ) { + setSelectedLabelForAnnotation(null); + setEditSegmentsMode(false); + + event.stopImmediatePropagation(); + event.preventDefault(); + } + }; + + document.addEventListener("keydown", handler); + + return () => { + document.removeEventListener("keydown", handler); + }; + }, [setSelectedLabelForAnnotation, mode, selectedLabelForAnnotation]); + const [overlayRotation, itemRotation] = useMemo( () => [ toEulerFromDegreesArray(_get(settings, "overlay.rotation", [0, 0, 0])), @@ -111,13 +197,6 @@ export const ThreeDLabels = ({ sampleMap }: ThreeDLabelsProps) => { [settings] ); - const canonicalSampleId = useMemo(() => { - const samples = Array.isArray(sampleMap) - ? sampleMap - : Object.values(sampleMap); - return samples[0].id ?? samples[0].sample?._id; - }, [sampleMap]); - const rawOverlays = useMemo( () => load3dOverlays(sampleMap, selectedLabels, [], schema) @@ -136,7 +215,18 @@ export const ThreeDLabels = ({ sampleMap }: ThreeDLabelsProps) => { return { ...l, color, id: l._id }; }) - .filter((l) => pathFilter(l.path, l)), + .filter((l) => { + if (!pathFilter(l.path, l)) { + return false; + } + + // In annotate mode, only show fields that exist in annotation schemas + if (mode === "annotate") { + return annotationSchemas && l.path in annotationSchemas; + } + + return true; + }), [ coloring, pathFilter, @@ -146,6 +236,8 @@ export const ThreeDLabels = ({ sampleMap }: ThreeDLabelsProps) => { selectedLabelTags, labelTagColors, customizeColorSetting, + mode, + annotationSchemas, ] ); const [cuboidOverlays, polylineOverlays] = useMemo(() => { @@ -161,11 +253,12 @@ export const ThreeDLabels = ({ sampleMap }: ThreeDLabelsProps) => { newCuboidOverlays.push( handleSelect(overlay, e)} + {...(overlay as CuboidProps)} + onClick={(e) => handleSelect(overlay, "cuboid", e)} label={overlay} tooltip={tooltip} useLegacyCoordinates={settings.useLegacyCoordinates} @@ -173,21 +266,100 @@ export const ThreeDLabels = ({ sampleMap }: ThreeDLabelsProps) => { ); } else if ( overlay._cls === "Polyline" && - (overlay as unknown as PolyLineProps).points3d + (overlay as PolyLineProps).points3d ) { newPolylineOverlays.push( handleSelect(overlay, e)} + onClick={(e) => handleSelect(overlay, "polyline", e)} tooltip={tooltip} /> ); } } + + // Check for any label ids in polylinePointTransformsAtom that are not in newPolylineOverlays + // and create new polyline overlays for them + const existingPolylineIds = new Set( + rawOverlays + .filter((overlay) => overlay._cls === "Polyline") + .map((overlay) => overlay._id) + ); + + for (const [labelId, transforms] of Object.entries( + polylinePointTransforms + )) { + if (transforms.length === 0 || existingPolylineIds.has(labelId)) { + continue; + } + + // Convert PolylinePointTransform[] to points3d format + const points3d: THREE.Vector3Tuple[][] = []; + + // Group transforms by segmentIndex + const segmentsMap = new Map(); + transforms.forEach((transform) => { + const { segmentIndex } = transform; + if (!segmentsMap.has(segmentIndex)) { + segmentsMap.set(segmentIndex, []); + } + segmentsMap.get(segmentIndex)!.push(transform); + }); + + // Create segments from grouped transforms + segmentsMap.forEach((segmentTransforms) => { + // Sort by pointIndex to maintain order + segmentTransforms.sort((a, b) => a.pointIndex - b.pointIndex); + + // Create segment points + const segmentPoints: THREE.Vector3Tuple[] = []; + segmentTransforms.forEach((transform) => { + segmentPoints[transform.pointIndex] = transform.position; + }); + + // Only add segment if it has at least 2 points + if (segmentPoints.length >= 2) { + points3d.push(segmentPoints); + } + }); + + // Only create overlay if we have valid segments + if (points3d.length > 0) { + const overlayLabel = { + _id: labelId, + _cls: "Polyline", + type: "Polyline", + // todo: THIS HAS TO CHANGE TO A REAL PATH + path: `polyline-${labelId.substring(0, 5)}`, + selected: false, + sampleId: currentSampleId, + tags: [], + points3d, + }; + + newPolylineOverlays.push( + handleSelect(overlayLabel, "polyline", e)} + tooltip={tooltip} + /> + ); + } + } + return [newCuboidOverlays, newPolylineOverlays]; }, [ rawOverlays, @@ -197,6 +369,10 @@ export const ThreeDLabels = ({ sampleMap }: ThreeDLabelsProps) => { handleSelect, tooltip, settings, + transformMode, + polylinePointTransforms, + polylineWidth, + currentSampleId, ]); const getOnShiftClickLabelCallback = useOnShiftClickLabel(); diff --git a/app/packages/looker-3d/src/labels/line.tsx b/app/packages/looker-3d/src/labels/line.tsx deleted file mode 100644 index c33e94f251b..00000000000 --- a/app/packages/looker-3d/src/labels/line.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { extend } from "@react-three/fiber"; -import React from "react"; -import { useRecoilValue } from "recoil"; -import * as THREE from "three"; -import { Line2 } from "three/examples/jsm/lines/Line2"; -import { LineGeometry } from "three/examples/jsm/lines/LineGeometry"; -import { LineMaterial } from "three/examples/jsm/lines/LineMaterial"; -import { polylineLabelLineWidthAtom } from "../state"; -import type { OverlayProps } from "./shared"; - -extend({ Line2, LineMaterial, LineGeometry }); - -interface LineProps extends Omit { - points: THREE.Vector3Tuple[]; -} - -export const Line = ({ rotation, points, color, opacity }: LineProps) => { - const lineWidth = useRecoilValue(polylineLabelLineWidthAtom); - const geo = React.useMemo(() => { - const g = new THREE.BufferGeometry().setFromPoints( - points.map((p) => new THREE.Vector3(...p)) - ); - g.rotateX(rotation[0]); - g.rotateY(rotation[1]); - g.rotateZ(rotation[2]); - return g; - }, [points, rotation]); - - const geometry = React.useMemo(() => { - return new LineGeometry().fromLine(new THREE.Line(geo)); - }, [geo]); - - const material = React.useMemo(() => { - return new LineMaterial({ - color: color, - linewidth: lineWidth, - opacity: opacity, - }); - }, [color, lineWidth, opacity]); - - return ( - - - - ); -}; diff --git a/app/packages/looker-3d/src/labels/polyline.tsx b/app/packages/looker-3d/src/labels/polyline.tsx index 0ee2267f955..cd988f64be1 100644 --- a/app/packages/looker-3d/src/labels/polyline.tsx +++ b/app/packages/looker-3d/src/labels/polyline.tsx @@ -1,16 +1,25 @@ -import { useCursor } from "@react-three/drei"; -import { useMemo, useState } from "react"; +import * as fos from "@fiftyone/state"; +import { Line as LineDrei } from "@react-three/drei"; +import { useAtomValue } from "jotai"; +import { useEffect, useMemo, useRef } from "react"; +import { useRecoilValue } from "recoil"; import * as THREE from "three"; -import { use3dLabelColor } from "../hooks/use-3d-label-color"; -import { useSimilarLabels3d } from "../hooks/use-similar-labels-3d"; -import { Line } from "./line"; +import { usePolylineAnnotation } from "../annotation/usePolylineAnnotation"; +import { + selectedLabelForAnnotationAtom, + tempLabelTransformsAtom, +} from "../state"; import { createFilledPolygonMeshes } from "./polygon-fill-utils"; import type { OverlayProps } from "./shared"; +import { useEventHandlers, useHoverState, useLabelColor } from "./shared/hooks"; +import { Transformable } from "./shared/TransformControls"; export interface PolyLineProps extends OverlayProps { + // Array of line segments, where each segment is an array of 3D points points3d: THREE.Vector3Tuple[][]; filled: boolean; - // we ignore closed for now + lineWidth?: number; + // We ignore closed for now closed?: boolean; } @@ -21,52 +30,103 @@ export const Polyline = ({ points3d, color, selected, + lineWidth, onClick, tooltip, label, }: PolyLineProps) => { - const { onPointerOver, onPointerOut, ...restEventHandlers } = useMemo(() => { - return { ...tooltip.getMeshProps(label) }; - }, [tooltip, label]); - - const [isPolylineHovered, setIsPolylineHovered] = useState(false); - const isSimilarLabelHovered = useSimilarLabels3d(label); - useCursor(isPolylineHovered); - - const strokeAndFillColor = use3dLabelColor({ - isSelected: selected, - isHovered: isPolylineHovered, - isSimilarLabelHovered, - defaultColor: color, + const meshesRef = useRef([]); + + const { isHovered, setIsHovered } = useHoverState(); + const { onPointerOver, onPointerOut, restEventHandlers } = useEventHandlers( + tooltip, + label + ); + + const isAnnotateMode = useAtomValue(fos.modalMode) === "annotate"; + const isSelectedForAnnotation = + useRecoilValue(selectedLabelForAnnotationAtom)?._id === label._id; + + const { strokeAndFillColor } = useLabelColor( + { selected, color }, + isHovered, + label, + isSelectedForAnnotation + ); + + const { + effectivePoints3d, + centroid, + transformControlsRef, + contentRef, + markers, + handleTransformStart, + handleTransformChange, + handleTransformEnd, + handlePointerOver: handleAnnotationPointerOver, + handlePointerOut: handleAnnotationPointerOut, + handleSegmentPointerOver, + handleSegmentPointerOut, + handleSegmentClick, + } = usePolylineAnnotation({ + label, + points3d, + strokeAndFillColor, + isAnnotateMode, + isSelectedForAnnotation, }); const lines = useMemo( () => - points3d.map((pts, i) => ( - ( + handleSegmentPointerOver(i)} + onPointerOut={handleSegmentPointerOut} + onClick={handleSegmentClick} /> )), - [points3d, rotation, opacity, strokeAndFillColor, label] + [ + effectivePoints3d, + strokeAndFillColor, + lineWidth, + rotation, + label._id, + handleSegmentPointerOver, + handleSegmentPointerOut, + handleSegmentClick, + ] ); - const filledMeshes = useMemo(() => { + const material = useMemo(() => { if (!filled) return null; - const material = new THREE.MeshBasicMaterial({ + return new THREE.MeshBasicMaterial({ color: strokeAndFillColor, opacity, transparent: true, side: THREE.DoubleSide, depthWrite: false, }); + }, [filled, strokeAndFillColor, opacity]); - const meshes = createFilledPolygonMeshes(points3d, material); + const filledMeshes = useMemo(() => { + if (!filled || !material) return null; + + // Dispose previous meshes + meshesRef.current.forEach((mesh) => { + if (mesh.geometry) mesh.geometry.dispose(); + }); + + const meshes = createFilledPolygonMeshes(effectivePoints3d, material); + meshesRef.current = meshes || []; if (!meshes) return null; @@ -77,42 +137,69 @@ export const Polyline = ({ rotation={rotation as unknown as THREE.Euler} /> )); - }, [filled, points3d, rotation, strokeAndFillColor, opacity, label._id]); + }, [filled, effectivePoints3d, rotation, material, label._id]); - if (filled && filledMeshes) { - return ( - { - setIsPolylineHovered(true); - onPointerOver(); - }} - onPointerOut={() => { - setIsPolylineHovered(false); - onPointerOut(); - }} - onClick={onClick} - {...restEventHandlers} - > - {filledMeshes} - {lines} - - ); - } + useEffect(() => { + return () => { + meshesRef.current.forEach((mesh) => { + if (mesh.geometry) { + mesh.geometry.dispose(); + } + }); + }; + }, []); + + useEffect(() => { + return () => { + if (material) { + material.dispose(); + } + }; + }, [material]); + + const tempTransforms = useRecoilValue(tempLabelTransformsAtom(label._id)); + + const content = ( + <> + {filled && filledMeshes} + {lines} + + ); return ( - { - setIsPolylineHovered(true); - onPointerOver(); - }} - onPointerOut={() => { - setIsPolylineHovered(false); - onPointerOut(); - }} - onClick={onClick} - {...restEventHandlers} + - {lines} - + + {markers} + { + setIsHovered(true); + handleAnnotationPointerOver(); + onPointerOver(); + }} + onPointerOut={() => { + setIsHovered(false); + handleAnnotationPointerOut(); + onPointerOut(); + }} + onClick={onClick} + {...restEventHandlers} + > + {content} + + + ); }; diff --git a/app/packages/looker-3d/src/labels/shared.tsx b/app/packages/looker-3d/src/labels/shared.tsx index 984912229dc..5103cd686f3 100644 --- a/app/packages/looker-3d/src/labels/shared.tsx +++ b/app/packages/looker-3d/src/labels/shared.tsx @@ -1,16 +1,5 @@ -import type { useTooltip } from "@fiftyone/state"; -import { ThreeEvent } from "@react-three/fiber"; -import type { Vector3Tuple } from "three"; -import type { OverlayLabel } from "./loader"; - -export interface OverlayProps { - rotation: Vector3Tuple; - opacity: number; - tooltip: ReturnType; - label: OverlayLabel; - color: string; - onClick: (e: ThreeEvent) => void; +import type { BaseOverlayProps } from "../types"; +export interface OverlayProps extends BaseOverlayProps { useLegacyCoordinates?: boolean; - selected?: boolean; } diff --git a/app/packages/looker-3d/src/labels/shared/TransformControls.tsx b/app/packages/looker-3d/src/labels/shared/TransformControls.tsx new file mode 100644 index 00000000000..b55927c930c --- /dev/null +++ b/app/packages/looker-3d/src/labels/shared/TransformControls.tsx @@ -0,0 +1,115 @@ +import * as fos from "@fiftyone/state"; +import { TransformControls } from "@react-three/drei"; +import { useAtomValue } from "jotai"; +import { useCallback, useEffect, useRef } from "react"; +import { useRecoilState, useRecoilValue } from "recoil"; +import * as THREE from "three"; +import { + currentArchetypeSelectedForTransformAtom, + isCurrentlyTransformingAtom, + transformModeAtom, + transformSpaceAtom, +} from "../../state"; +import type { TransformArchetype, TransformProps } from "../../types"; + +type TransformableProps = { + archetype: TransformArchetype; + explicitObjectRef?: React.RefObject; + transformControlsPosition?: THREE.Vector3Tuple; + children: React.ReactNode; +} & Pick< + TransformProps, + | "isSelectedForTransform" + | "onTransformStart" + | "onTransformEnd" + | "onTransformChange" + | "transformControlsRef" + | "translationSnap" + | "rotationSnap" + | "scaleSnap" + | "showX" + | "showY" + | "showZ" +>; + +/** + * Shared component for rendering transform controls. + */ +export const Transformable = ({ + archetype, + children, + explicitObjectRef, + isSelectedForTransform, + onTransformStart, + onTransformEnd, + onTransformChange, + transformControlsRef, + transformControlsPosition = [0, 0, 0], + ...transformControlsProps +}: TransformableProps) => { + const groupRef = useRef(null); + + const modalMode = useAtomValue(fos.modalMode); + const transformMode = useRecoilValue(transformModeAtom); + const transformSpace = useRecoilValue(transformSpaceAtom); + const currentArchetypeSelectedForTransform = useRecoilValue( + currentArchetypeSelectedForTransformAtom + ); + const [isCurrentlyTransforming, setIsCurrentlyTransforming] = useRecoilState( + isCurrentlyTransformingAtom + ); + const isAnnotateMode = modalMode === "annotate"; + + const onTransformStartDecorated = useCallback(() => { + setIsCurrentlyTransforming(true); + onTransformStart?.(); + }, [onTransformStart, archetype]); + + const onTransformEndDecorated = useCallback(() => { + setIsCurrentlyTransforming(false); + onTransformEnd?.(); + }, [onTransformEnd, archetype]); + + const onObjectChangeDecorated = useCallback(() => { + onTransformChange?.(); + }, [onTransformChange]); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape" && isCurrentlyTransforming) { + setIsCurrentlyTransforming(false); + event.stopImmediatePropagation(); + event.preventDefault(); + } + }; + + document.addEventListener("keydown", handleKeyDown); + + return () => { + document.removeEventListener("keydown", handleKeyDown); + }; + }, [archetype, isCurrentlyTransforming]); + + return ( + <> + {explicitObjectRef ? children : {children}} + + {isAnnotateMode && + isSelectedForTransform && + currentArchetypeSelectedForTransform === archetype && ( + + + + )} + + ); +}; diff --git a/app/packages/looker-3d/src/labels/shared/hooks.ts b/app/packages/looker-3d/src/labels/shared/hooks.ts new file mode 100644 index 00000000000..ec372825e50 --- /dev/null +++ b/app/packages/looker-3d/src/labels/shared/hooks.ts @@ -0,0 +1,69 @@ +import { useCursor } from "@react-three/drei"; +import { useMemo, useState } from "react"; +import { useRecoilValue } from "recoil"; +import { use3dLabelColor } from "../../hooks/use-3d-label-color"; +import { useSimilarLabels3d } from "../../hooks/use-similar-labels-3d"; +import { editSegmentsModeAtom, segmentPolylineStateAtom } from "../../state"; +import type { BaseOverlayProps, EventHandlers, HoverState } from "../../types"; + +/** + * Custom hook for managing hover state and cursor behavior + */ +export const useHoverState = (): HoverState => { + const isSegmenting = useRecoilValue(segmentPolylineStateAtom).isActive; + const [isHovered, setIsHovered] = useState(false); + const isEditSegmentsMode = useRecoilValue(editSegmentsModeAtom); + + useCursor( + isHovered, + isSegmenting || isEditSegmentsMode ? "crosshair" : "pointer", + isSegmenting ? "crosshair" : "auto" + ); + + return { + isHovered, + setIsHovered, + }; +}; + +/** + * Custom hook for managing event handlers and tooltip integration + */ +export const useEventHandlers = (tooltip: any, label: any): EventHandlers => { + const { onPointerOver, onPointerOut, ...restEventHandlers } = useMemo(() => { + return { + ...tooltip.getMeshProps(label), + }; + }, [tooltip, label]); + + return { + onPointerOver, + onPointerOut, + restEventHandlers, + }; +}; + +/** + * Custom hook for managing color logic with selection, hover, and similarity states + */ +export const useLabelColor = ( + props: Pick, + isHovered: boolean, + label: any, + isSelectedForAnnotation?: boolean +) => { + const isSimilarLabelHovered = useSimilarLabels3d(label); + + const strokeAndFillColor = use3dLabelColor({ + isSelected: props.selected, + isHovered, + isSimilarLabelHovered, + defaultColor: props.color, + isSelectedForAnnotation, + }); + + return { + strokeAndFillColor, + isSimilarLabelHovered, + }; +}; diff --git a/app/packages/looker-3d/src/labels/shared/index.ts b/app/packages/looker-3d/src/labels/shared/index.ts new file mode 100644 index 00000000000..cf04505f1d7 --- /dev/null +++ b/app/packages/looker-3d/src/labels/shared/index.ts @@ -0,0 +1,2 @@ +export * from "./hooks"; +export * from "./TransformControls"; diff --git a/app/packages/looker-3d/src/renderables/pcd/index.tsx b/app/packages/looker-3d/src/renderables/pcd/index.tsx index b3dd80d2d50..7fe795ea7f2 100644 --- a/app/packages/looker-3d/src/renderables/pcd/index.tsx +++ b/app/packages/looker-3d/src/renderables/pcd/index.tsx @@ -90,7 +90,7 @@ export const PointCloudMesh = ({ return null; } - if (minZ) { + if (minZ !== null && minZ !== undefined) { return minZ; } diff --git a/app/packages/looker-3d/src/state.ts b/app/packages/looker-3d/src/state.ts index da7c1e5f5a5..1aa22768384 100644 --- a/app/packages/looker-3d/src/state.ts +++ b/app/packages/looker-3d/src/state.ts @@ -6,10 +6,23 @@ import { nullableModalSampleId, } from "@fiftyone/state"; import { atom, atomFamily, selector } from "recoil"; -import { Vector3 } from "three"; +import { Vector3, Vector3Tuple } from "three"; +import type { + AnnotationPlaneState, + HoveredPolylineInfo, + PolylinePointTransform, + SegmentPolylineState, + SelectedPoint, + TempPolyline, + TransformedLabelData, + TransformMode, + TransformSpace, +} from "./annotation/types"; import { SHADE_BY_HEIGHT } from "./constants"; import type { FoSceneNode } from "./hooks"; +import { OverlayLabel } from "./labels/loader"; import type { Actions, AssetLoadingLog, ShadeBy } from "./types"; +import { TransformArchetype } from "./types"; const fo3dAssetsParseStatusLog = atomFamily({ key: "fo3d-assetsParseStatusLogs", @@ -42,7 +55,9 @@ export const cameraPositionAtom = atom<[number, number, number] | null>({ export const shadeByAtom = atom({ key: "fo3d-shadeBy", default: SHADE_BY_HEIGHT, - effects: [getBrowserStorageEffectForKey("shadeBy")], + effects: [ + getBrowserStorageEffectForKey("shadeBy", { prependDatasetNameInKey: true }), + ], }); export const customColorMapAtom = atom<{ [slice: string]: string } | null>({ @@ -51,6 +66,7 @@ export const customColorMapAtom = atom<{ [slice: string]: string } | null>({ effects: [ getBrowserStorageEffectForKey("customColorMap", { useJsonSerialization: true, + prependDatasetNameInKey: true, }), ], }); @@ -81,7 +97,11 @@ export const currentActionAtom = atom({ export const currentPointSizeAtom = atom({ key: "fo3d-pointSize", default: "2", - effects: [getBrowserStorageEffectForKey("pointSize")], + effects: [ + getBrowserStorageEffectForKey("pointSize", { + prependDatasetNameInKey: true, + }), + ], }); export const pointSizeRangeAtom = atom({ @@ -114,6 +134,16 @@ export const isLevaConfigPanelOnAtom = atom({ default: false, }); +export const annotationToolbarPositionAtom = atom({ + key: "fo3d-annotationToolbarPosition", + default: 50, + effects: [ + getBrowserStorageEffectForKey("fo3d-annotationToolbarPosition", { + valueClass: "number", + }), + ], +}); + export const gridCellSizeAtom = atom({ key: "fo3d-gridCellSize", default: 1, @@ -161,6 +191,7 @@ export const isFo3dBackgroundOnAtom = atom({ effects: [ getBrowserStorageEffectForKey("fo3d-isBackgroundON", { valueClass: "boolean", + prependDatasetNameInKey: true, }), ], }); @@ -180,6 +211,17 @@ export const currentHoveredPointAtom = atom({ default: null, }); +// Hover state for labels in annotate mode +export const hoveredLabelAtom = atom({ + key: "fo3d-hoveredLabel", + default: null, +}); + +export const hoveredPolylineInfoAtom = atom({ + key: "fo3d-hoveredPolylineInfo", + default: null, +}); + export const cuboidLabelLineWidthAtom = atom({ key: "fo3d-cuboidLabelLineWidth", default: 3, @@ -199,3 +241,179 @@ export const polylineLabelLineWidthAtom = atom({ }), ], }); + +export const selectedLabelForAnnotationAtom = atom({ + key: "fo3d-selectedLabelForAnnotation", + default: null, +}); + +export const transformModeAtom = atom({ + key: "fo3d-transformMode", + default: "translate", +}); + +export const transformSpaceAtom = atom({ + key: "fo3d-transformSpace", + default: "world", +}); + +export const currentArchetypeSelectedForTransformAtom = + atom({ + key: "fo3d-currentArchetypeSelectedForTransformAtom", + default: null, + }); + +// True if ANY entity is being actively transformed +export const isCurrentlyTransformingAtom = atom({ + key: "fo3d-isCurrentlyTransformingAtom", + default: false, +}); + +// todo: we sync this with backend +export const transformedLabelsAtom = atom>( + { + key: "fo3d-transformedLabels", + default: {}, + } +); + +export const selectedPolylineVertexAtom = atom({ + key: "fo3d-selectedPolylineVertexAtom", + default: null, +}); + +export const polylinePointTransformsAtom = atom< + Record +>({ + key: "fo3d-polylinePointTransforms", + default: {}, +}); + +export const polylineEffectivePointsAtom = atomFamily( + { + key: "fo3d-polylineEffectivePoints", + default: [], + } +); + +export const segmentPolylineStateAtom = atom({ + key: "fo3d-segmentPolylineState", + default: { + isActive: false, + vertices: [], + currentMousePosition: null, + isClosed: false, + }, +}); + +export const isActivelySegmentingSelector = selector({ + key: "fo3d-isActivelySegmentingSelector", + get: ({ get }) => { + return get(segmentPolylineStateAtom).isActive; + }, +}); + +export const isSegmentingPointerDownAtom = atom({ + key: "fo3d-isSegmentingPointerDownAtom", + default: false, +}); + +export const tempPolylinesAtom = atom({ + key: "fo3d-tempPolylines", + default: [], +}); + +export const snapCloseAutomaticallyAtom = atom({ + key: "fo3d-snapCloseAutomatically", + default: false, +}); + +export const editSegmentsModeAtom = atom({ + key: "fo3d-editSegmentsMode", + default: false, +}); + +export const sharedCursorPositionAtom = atom<[number, number, number] | null>({ + key: "fo3d-sharedCursorPosition", + default: null, +}); + +export const tempLabelTransformsAtom = atomFamily< + { + position: [number, number, number]; + quaternion: [number, number, number, number]; + } | null, + string +>({ + key: "fo3d-tempLabelTransforms", + default: null, +}); + +export const tempVertexTransformsAtom = atomFamily< + { + position: [number, number, number]; + quaternion: [number, number, number, number]; + } | null, + string +>({ + key: "fo3d-tempVertexTransforms", + default: null, +}); + +export const annotationPlaneAtom = atom({ + key: "fo3d-annotationPlane", + default: { + enabled: false, + position: [0, 0, 0], + quaternion: [0, 0, 0, 1], + showX: true, + showY: true, + showZ: true, + }, + effects: [ + getBrowserStorageEffectForKey("fo3d-annotationPlane", { + useJsonSerialization: true, + sessionStorage: true, + }), + ], +}); + +export const isSnapToAnnotationPlaneAtom = atom({ + key: "fo3d-isSnapToAnnotationPlane", + default: true, + effects: [ + getBrowserStorageEffectForKey("fo3d-isSnapToAnnotationPlane", { + valueClass: "boolean", + }), + ], +}); + +export const avoidZFightingAtom = atom({ + key: "fo3d-avoidZFighting", + default: true, + effects: [ + getBrowserStorageEffectForKey("fo3d-avoidZFighting", { + valueClass: "boolean", + }), + ], +}); + +// Selector to clear all transform state +export const clearTransformStateSelector = selector({ + key: "fo3d-clearTransformState", + get: () => null, + set: ({ set }) => { + set(transformModeAtom, "translate"); + set(transformSpaceAtom, "world"); + set(selectedPolylineVertexAtom, null); + set(currentArchetypeSelectedForTransformAtom, null); + set(isCurrentlyTransformingAtom, false); + // Note: We don't clear polylinePointTransforms here as it should persist + set(segmentPolylineStateAtom, { + isActive: false, + vertices: [], + currentMousePosition: null, + isClosed: false, + }); + }, +}); diff --git a/app/packages/looker-3d/src/types.ts b/app/packages/looker-3d/src/types.ts index 13556a30320..010dfb44187 100644 --- a/app/packages/looker-3d/src/types.ts +++ b/app/packages/looker-3d/src/types.ts @@ -1,3 +1,6 @@ +import { TransformControlsProps } from "@react-three/drei"; +import type { RefObject } from "react"; +import * as THREE from "three"; import type { ACTION_SET_PCDS, ACTION_SET_POINT_SIZE, @@ -10,6 +13,7 @@ import type { SHADE_BY_NONE, SHADE_BY_RGB, } from "./constants"; +import { OverlayLabel } from "./labels/loader"; export type Actions = | typeof ACTION_SHADE_BY @@ -42,3 +46,38 @@ export type AssetLoadingLog = { message: string; status: "info" | "success" | "error"; }; + +export interface BaseOverlayProps { + opacity: number; + rotation: THREE.Vector3Tuple; + selected: boolean; + onClick: (e: any) => void; + tooltip: any; + label: OverlayLabel; + color: string; +} + +export interface TransformProps extends TransformControlsProps { + isSelectedForTransform?: boolean; + onTransformStart?: () => void; + onTransformEnd?: () => void; + onTransformChange?: () => void; + transformControlsRef?: RefObject; +} + +export interface HoverState { + isHovered: boolean; + setIsHovered: (hovered: boolean) => void; +} + +export interface EventHandlers { + onPointerOver: () => void; + onPointerOut: () => void; + restEventHandlers: Record; +} + +export type TransformArchetype = + | "point" + | "cuboid" + | "polyline" + | "annotation-plane"; diff --git a/app/packages/looker-3d/src/utils.test.ts b/app/packages/looker-3d/src/utils.test.ts index 2d994cea6a2..32a8cd83848 100644 --- a/app/packages/looker-3d/src/utils.test.ts +++ b/app/packages/looker-3d/src/utils.test.ts @@ -1,13 +1,24 @@ -import { BufferAttribute, Quaternion, Vector3 } from "three"; -import { describe, expect, it } from "vitest"; +import { + BufferAttribute, + PerspectiveCamera, + Plane, + Quaternion, + Raycaster, + Vector3, +} from "three"; +import { describe, expect, it, vi } from "vitest"; import { COLOR_POOL } from "./constants"; import { computeMinMaxForColorBufferAttribute, computeMinMaxForScalarBufferAttribute, + createPlane, deg2rad, getColorFromPoolBasedOnHash, getGridQuaternionFromUpVector, + getPlaneFromPositionAndQuaternion, + getPlaneIntersection, toEulerFromDegreesArray, + toNDC, } from "./utils"; describe("deg2rad", () => { @@ -83,14 +94,311 @@ describe("getColorFromPoolBasedOnHash", () => { }); describe("getGridQuaternionFromUpVector", () => { - it("returns a quaternion for up vector", () => { + it("returns identity quaternion when up vector matches target normal", () => { const up = new Vector3(0, 1, 0); const result = getGridQuaternionFromUpVector(up); - console.log(result); expect(result).toBeInstanceOf(Quaternion); - expect(result.w).toBe(1); + expect(result.w).toBeCloseTo(1); expect(result.x).toBeCloseTo(0); expect(result.y).toBeCloseTo(0); expect(result.z).toBeCloseTo(0); }); + + it("handles antiparallel up vector (opposite direction)", () => { + const up = new Vector3(0, -1, 0); // Opposite to target normal (0, 1, 0) + const result = getGridQuaternionFromUpVector(up); + expect(result).toBeInstanceOf(Quaternion); + // Should not be zero quaternion (0, 0, 0, 0) - at least one component should be non-zero + const hasNonZeroComponent = + result.w !== 0 || result.x !== 0 || result.y !== 0 || result.z !== 0; + expect(hasNonZeroComponent).toBe(true); + // Verify it's a valid quaternion (magnitude should be 1) + const magnitude = Math.sqrt( + result.w * result.w + + result.x * result.x + + result.y * result.y + + result.z * result.z + ); + expect(magnitude).toBeCloseTo(1); + const targetNormal = new Vector3(0, 1, 0); + const rotated = up.clone().applyQuaternion(result); + expect(rotated.x).toBeCloseTo(targetNormal.x); + expect(rotated.y).toBeCloseTo(targetNormal.y); + expect(rotated.z).toBeCloseTo(targetNormal.z); + }); + + it("handles arbitrary up vector", () => { + const up = new Vector3(1, 0, 0).normalize(); + const result = getGridQuaternionFromUpVector(up); + expect(result).toBeInstanceOf(Quaternion); + // Should not be zero quaternion + expect(result.w).not.toBe(0); + // Verify it's a valid quaternion + const magnitude = Math.sqrt( + result.w * result.w + + result.x * result.x + + result.y * result.y + + result.z * result.z + ); + expect(magnitude).toBeCloseTo(1); + }); + + it("handles diagonal up vector", () => { + const up = new Vector3(1, 1, 0).normalize(); + const result = getGridQuaternionFromUpVector(up); + expect(result).toBeInstanceOf(Quaternion); + // Should not be zero quaternion + expect(result.w).not.toBe(0); + // Verify it's a valid quaternion + const magnitude = Math.sqrt( + result.w * result.w + + result.x * result.x + + result.y * result.y + + result.z * result.z + ); + expect(magnitude).toBeCloseTo(1); + }); + + it("handles custom target normal", () => { + const up = new Vector3(0, 0, 1); + const targetNormal = new Vector3(0, 0, 1); + const result = getGridQuaternionFromUpVector(up, targetNormal); + expect(result).toBeInstanceOf(Quaternion); + // Should be identity quaternion when vectors match + expect(result.w).toBeCloseTo(1); + expect(result.x).toBeCloseTo(0); + expect(result.y).toBeCloseTo(0); + expect(result.z).toBeCloseTo(0); + }); + + it("handles antiparallel with custom target normal", () => { + const up = new Vector3(0, 0, -1); + const targetNormal = new Vector3(0, 0, 1); + const result = getGridQuaternionFromUpVector(up, targetNormal); + expect(result).toBeInstanceOf(Quaternion); + // Should not be zero quaternion (0, 0, 0, 0) - at least one component should be non-zero + const hasNonZeroComponent = + result.w !== 0 || result.x !== 0 || result.y !== 0 || result.z !== 0; + expect(hasNonZeroComponent).toBe(true); + // Verify it's a valid quaternion (magnitude should be 1) + const magnitude = Math.sqrt( + result.w * result.w + + result.x * result.x + + result.y * result.y + + result.z * result.z + ); + expect(magnitude).toBeCloseTo(1); + const rotated = up.clone().applyQuaternion(result); + expect(rotated.x).toBeCloseTo(targetNormal.x); + expect(rotated.y).toBeCloseTo(targetNormal.y); + expect(rotated.z).toBeCloseTo(targetNormal.z); + }); +}); + +describe("toNDC", () => { + it("converts pointer event to normalized device coordinates", () => { + const mockCanvas = { + getBoundingClientRect: vi.fn().mockReturnValue({ + left: 100, + top: 50, + width: 800, + height: 600, + }), + } as unknown as HTMLCanvasElement; + + const mockEvent = { + clientX: 500, + clientY: 350, + } as PointerEvent; + + const result = toNDC(mockEvent, mockCanvas); + + // Center of canvas should be (0, 0) in NDC + expect(result.x).toBeCloseTo(0); + expect(result.y).toBeCloseTo(0); + }); + + it("converts corner coordinates correctly", () => { + const mockCanvas = { + getBoundingClientRect: vi.fn().mockReturnValue({ + left: 0, + top: 0, + width: 100, + height: 100, + }), + } as unknown as HTMLCanvasElement; + + // Top-left corner + const topLeftEvent = { + clientX: 0, + clientY: 0, + } as PointerEvent; + const topLeftResult = toNDC(topLeftEvent, mockCanvas); + expect(topLeftResult.x).toBeCloseTo(-1); + expect(topLeftResult.y).toBeCloseTo(1); + + // Bottom-right corner + const bottomRightEvent = { + clientX: 100, + clientY: 100, + } as PointerEvent; + const bottomRightResult = toNDC(bottomRightEvent, mockCanvas); + expect(bottomRightResult.x).toBeCloseTo(1); + expect(bottomRightResult.y).toBeCloseTo(-1); + }); +}); + +describe("createPlane", () => { + it("creates a plane with normalized normal and negative constant", () => { + const normal = new Vector3(2, 0, 0); + const constant = 5; + const plane = createPlane(normal, constant); + + expect(plane).toBeInstanceOf(Plane); + expect(plane.normal.x).toBeCloseTo(1); + expect(plane.normal.y).toBeCloseTo(0); + expect(plane.normal.z).toBeCloseTo(0); + expect(plane.constant).toBe(-5); + }); +}); + +describe("getPlaneIntersection", () => { + it("returns intersection point when ray intersects plane", () => { + const raycaster = new Raycaster(); + const camera = new PerspectiveCamera(75, 1, 0.1, 1000); + camera.position.set(0, 0, 5); + camera.lookAt(0, 0, 0); + + // XY plane at z=0 + const plane = new Plane(new Vector3(0, 0, 1), 0); + // Center of screen + const ndc = { x: 0, y: 0 }; + + const result = getPlaneIntersection(raycaster, camera, ndc, plane); + + expect(result).toBeInstanceOf(Vector3); + expect(result?.x).toBeCloseTo(0); + expect(result?.y).toBeCloseTo(0); + expect(result?.z).toBeCloseTo(0); + }); + + it("returns null when ray does not intersect plane", () => { + const raycaster = new Raycaster(); + const camera = new PerspectiveCamera(75, 1, 0.1, 1000); + camera.position.set(0, 0, 5); + camera.lookAt(0, 0, 0); + + // Create a plane that the ray will miss - plane is behind the camera and ray goes forward + // XY plane at z=-10 (behind camera) + const plane = new Plane(new Vector3(0, 0, -1), 10); + // Center of screen - ray goes forward from z=5 to z=0 + const ndc = { x: 0, y: 0 }; + + const result = getPlaneIntersection(raycaster, camera, ndc, plane); + + expect(result).toBeNull(); + }); +}); + +describe("getPlaneFromPositionAndQuaternion", () => { + it("creates plane with identity quaternion at origin", () => { + const position: [number, number, number] = [0, 0, 0]; + const quaternion: [number, number, number, number] = [0, 0, 0, 1]; + const result = getPlaneFromPositionAndQuaternion(position, quaternion); + + expect(result).toBeInstanceOf(Plane); + expect(result.normal.x).toBeCloseTo(0); + expect(result.normal.y).toBeCloseTo(0); + expect(result.normal.z).toBeCloseTo(1); + expect(result.constant).toBeCloseTo(0); + }); + + it("creates plane with identity quaternion at offset position", () => { + const position: [number, number, number] = [1, 2, 3]; + const quaternion: [number, number, number, number] = [0, 0, 0, 1]; + const result = getPlaneFromPositionAndQuaternion(position, quaternion); + + expect(result).toBeInstanceOf(Plane); + expect(result.normal.x).toBeCloseTo(0); + expect(result.normal.y).toBeCloseTo(0); + expect(result.normal.z).toBeCloseTo(1); + expect(result.constant).toBeCloseTo(-3); + }); + + it("creates plane with 90-degree rotation around Y-axis", () => { + const position: [number, number, number] = [0, 0, 0]; + const quaternion: [number, number, number, number] = [ + 0, + Math.sqrt(2) / 2, + 0, + Math.sqrt(2) / 2, + ]; + const result = getPlaneFromPositionAndQuaternion(position, quaternion); + + expect(result).toBeInstanceOf(Plane); + expect(result.normal.x).toBeCloseTo(1); + expect(result.normal.y).toBeCloseTo(0); + expect(result.normal.z).toBeCloseTo(0); + expect(result.constant).toBeCloseTo(0); + }); + + it("creates plane with 90-degree rotation around X-axis", () => { + const position: [number, number, number] = [0, 0, 0]; + const quaternion: [number, number, number, number] = [ + Math.sqrt(2) / 2, + 0, + 0, + Math.sqrt(2) / 2, + ]; + const result = getPlaneFromPositionAndQuaternion(position, quaternion); + + expect(result).toBeInstanceOf(Plane); + expect(result.normal.x).toBeCloseTo(0); + expect(result.normal.y).toBeCloseTo(-1); + expect(result.normal.z).toBeCloseTo(0); + expect(result.constant).toBeCloseTo(0); + }); + + it("creates plane with 180-degree rotation around X-axis", () => { + const position: [number, number, number] = [0, 0, 0]; + const quaternion: [number, number, number, number] = [1, 0, 0, 0]; + const result = getPlaneFromPositionAndQuaternion(position, quaternion); + + expect(result).toBeInstanceOf(Plane); + expect(result.normal.x).toBeCloseTo(0); + expect(result.normal.y).toBeCloseTo(0); + expect(result.normal.z).toBeCloseTo(-1); + expect(result.constant).toBeCloseTo(0); + }); + + it("creates plane with arbitrary rotation and position", () => { + const position: [number, number, number] = [2, 3, 4]; + const quaternion: [number, number, number, number] = [0.1, 0.2, 0.3, 0.9]; + const result = getPlaneFromPositionAndQuaternion(position, quaternion); + + expect(result).toBeInstanceOf(Plane); + // Verify the normal is normalized + const normalMagnitude = Math.sqrt( + result.normal.x * result.normal.x + + result.normal.y * result.normal.y + + result.normal.z * result.normal.z + ); + expect(normalMagnitude).toBeCloseTo(1); + }); + + it("creates plane with diagonal normal from quaternion", () => { + const position: [number, number, number] = [0, 0, 0]; + // Quaternion that rotates Z-axis to diagonal direction + const quaternion: [number, number, number, number] = [0.5, 0.5, 0.5, 0.5]; + const result = getPlaneFromPositionAndQuaternion(position, quaternion); + + expect(result).toBeInstanceOf(Plane); + // Verify the normal is normalized + const normalMagnitude = Math.sqrt( + result.normal.x * result.normal.x + + result.normal.y * result.normal.y + + result.normal.z * result.normal.z + ); + expect(normalMagnitude).toBeCloseTo(1); + }); }); diff --git a/app/packages/looker-3d/src/utils.ts b/app/packages/looker-3d/src/utils.ts index 6fe807fc5c0..581f26279e7 100644 --- a/app/packages/looker-3d/src/utils.ts +++ b/app/packages/looker-3d/src/utils.ts @@ -1,7 +1,11 @@ import { type BufferAttribute, + Box3, + type Camera, type InterleavedBufferAttribute, + Plane, Quaternion, + type Raycaster, Vector3, type Vector3Tuple, type Vector4Tuple, @@ -86,6 +90,9 @@ class InvalidSceneError extends Error { /** * Reads a raw JSON scene from FiftyOne and returns counts * of different media types in the scene. + * + * @param scene - The FiftyOne scene JSON object + * @returns Object containing counts of different media types */ export const getFiftyoneSceneSummary = (scene: FiftyoneSceneRawJson) => { if ( @@ -136,11 +143,17 @@ export const getFiftyoneSceneSummary = (scene: FiftyoneSceneRawJson) => { /** * Converts degrees to radians. + * + * @param degrees - The angle in degrees + * @returns The angle in radians */ export const deg2rad = (degrees: number) => degrees * (Math.PI / 180); /** * Converts an array of degrees to an array of radians. + * + * @param degreesArr - Array of angles in degrees + * @returns Array of angles in radians */ export const toEulerFromDegreesArray = (degreesArr: Vector3Tuple) => { return degreesArr.map(deg2rad) as Vector3Tuple; @@ -148,6 +161,9 @@ export const toEulerFromDegreesArray = (degreesArr: Vector3Tuple) => { /** * Computes the min and max values for a color buffer attribute. + * + * @param colorAttribute - The color buffer attribute to analyze + * @returns Object containing min and max values */ export const computeMinMaxForColorBufferAttribute = ( colorAttribute: BufferAttribute | InterleavedBufferAttribute @@ -166,6 +182,9 @@ export const computeMinMaxForColorBufferAttribute = ( /** * Computes the min and max values for a scalar buffer attribute (like intensity in pcd) + * + * @param attribute - The scalar buffer attribute to analyze + * @returns Object containing min and max values */ export const computeMinMaxForScalarBufferAttribute = ( attribute: BufferAttribute | InterleavedBufferAttribute @@ -181,6 +200,12 @@ export const computeMinMaxForScalarBufferAttribute = ( return { min: mn, max: mx }; }; +/** + * Gets a color from the color pool based on a string hash. + * + * @param str - The string to hash + * @returns A color from the color pool + */ export const getColorFromPoolBasedOnHash = (str: string) => { const hash = str.split("").reduce((acc, char, idx) => { const charCode = char.charCodeAt(0); @@ -189,15 +214,130 @@ export const getColorFromPoolBasedOnHash = (str: string) => { return COLOR_POOL[hash % COLOR_POOL.length]; }; -export const getGridQuaternionFromUpVector = (upVectorNormalized: Vector3) => { - // calculate angle between custom up direction and default up direction (y-axis in three-js) - const angle = Math.acos(upVectorNormalized.dot(new Vector3(0, 1, 0))); +/** + * Calculates a quaternion to align a grid with a custom up vector. + * + * @param upVectorNormalized - The normalized up vector + * @returns A quaternion representing the rotation + */ +export const getGridQuaternionFromUpVector = ( + upVectorNormalized: Vector3, + targetNormal: Vector3 = new Vector3(0, 1, 0) +) => { + const from = targetNormal.clone().normalize(); + const to = upVectorNormalized.clone(); + return new Quaternion().setFromUnitVectors(from, to); +}; + +/** + * Converts a pointer event to Normalized Device Coordinates (NDC). + * NDC coordinates range from -1 to 1 in both x and y directions, + * with (0, 0) at the center of the canvas. + * + * @param ev - The pointer event to convert + * @param canvas - The HTML canvas element to get bounds from + * @returns An object with x and y coordinates in NDC space + */ +export function toNDC(ev: PointerEvent, canvas: HTMLCanvasElement) { + const rect = canvas.getBoundingClientRect(); + return { + x: ((ev.clientX - rect.left) / rect.width) * 2 - 1, + y: -((ev.clientY - rect.top) / rect.height) * 2 + 1, + }; +} - // calculate axis perpendicular to both the default up direction and the custom up direction - const axis = new Vector3() - .crossVectors(new Vector3(0, 1, 0), upVectorNormalized) - .normalize(); +/** + * Calculates the intersection point between a ray and a plane. + * + * @param raycaster - The THREE.js raycaster instance + * @param camera - The THREE.js camera + * @param ndc - Normalized device coordinates + * @param plane - The THREE.js plane to intersect with + * @returns The intersection point if it exists, null otherwise + */ +export function getPlaneIntersection( + raycaster: Raycaster, + camera: Camera, + ndc: { x: number; y: number }, + plane: Plane +): Vector3 | null { + raycaster.setFromCamera(ndc as any, camera); + const point = new Vector3(); + if (raycaster.ray.intersectPlane(plane, point)) { + return point; + } + return null; +} - // quaternion to represent the rotation around an axis perpendicular to both the default up direction and the custom up direction - return new Quaternion().setFromAxisAngle(axis, angle); -}; +/** + * Creates a THREE.js plane from normal and constant values. + * + * @param normal - The plane normal vector + * @param constant - The plane constant + * @returns A new THREE.js plane + */ +export function createPlane(normal: Vector3, constant: number): Plane { + return new Plane(normal.clone().normalize(), -constant); +} + +/** + * Checks if a pointer event matches the specified button. + * + * @param ev - The pointer event to check + * @param button - The button number to match (0 = left, 1 = middle, 2 = right) + * @returns True if the button matches, false otherwise + */ +export function isButtonMatch(ev: PointerEvent, button: number): boolean { + return ev.button === button; +} + +/** + * Creates a THREE.js plane from stored position and quaternion state. + * + * @param position - The plane position as [x, y, z] + * @param quaternion - The plane rotation as [x, y, z, w] + * @returns A new THREE.js plane + */ +export function getPlaneFromPositionAndQuaternion( + position: [number, number, number], + quaternion: [number, number, number, number] +): Plane { + const pos = new Vector3(...position); + const quat = new Quaternion(...quaternion); + + // Extract normal from quaternion by rotating the Z-axis + const normal = new Vector3(0, 0, 1).applyQuaternion(quat).normalize(); + + // Create plane with normal and position + const plane = new Plane(); + plane.setFromNormalAndCoplanarPoint(normal, pos); + + return plane; +} + +/** + * Expands a bounding box by a specified safety margin factor. + * The expansion is applied uniformly in all directions from the center. + * + * @param boundingBox - The original bounding box to expand + * @param safetyMargin - The expansion factor (e.g., 1.5 for 1.5x expansion) + * @returns A new expanded bounding box + */ +export function expandBoundingBox( + boundingBox: Box3, + safetyMargin: number = 1.5 +): Box3 { + if (!boundingBox || boundingBox.isEmpty()) { + return boundingBox; + } + + const center = boundingBox.getCenter(new Vector3()); + const size = boundingBox.getSize(new Vector3()); + + const expandedSize = size.clone().multiplyScalar(safetyMargin); + + const expandedBox = new Box3(); + expandedBox.setFromCenterAndSize(center, expandedSize); + + return expandedBox; +} diff --git a/app/packages/looker-3d/src/vite-env.d.ts b/app/packages/looker-3d/src/vite-env.d.ts index 11f02fe2a00..b1f45c78666 100644 --- a/app/packages/looker-3d/src/vite-env.d.ts +++ b/app/packages/looker-3d/src/vite-env.d.ts @@ -1 +1,2 @@ /// +/// diff --git a/app/packages/state/src/recoil/atoms.ts b/app/packages/state/src/recoil/atoms.ts index e13d3b49532..010b9ad8bf1 100644 --- a/app/packages/state/src/recoil/atoms.ts +++ b/app/packages/state/src/recoil/atoms.ts @@ -407,3 +407,8 @@ export const editingFieldAtom = atom({ key: "editingFieldAtom", default: false, }); + +export const isInMultiPanelViewAtom = atom({ + key: "isInMultiPanelViewAtom", + default: false, +}); diff --git a/app/yarn.lock b/app/yarn.lock index b260dd4d38b..0d895fa901c 100644 --- a/app/yarn.lock +++ b/app/yarn.lock @@ -2559,13 +2559,15 @@ __metadata: "@biomejs/biome": "npm:^1.8.3" "@emotion/react": "npm:^11.14.0" "@emotion/styled": "npm:^11.14.0" - "@react-three/drei": "npm:^10.6.0" + "@react-three/drei": "npm:^10.7.6" "@react-three/fiber": "npm:^8.18.0" + "@types/chroma-js": "npm:^3.1.1" "@types/node": "npm:^20.11.10" "@types/react": "npm:^18.2.48" "@types/react-dom": "npm:^18.2.18" "@types/three": "npm:^0.176.0" "@vitejs/plugin-react": "npm:^1.3.0" + chroma-js: "npm:^3.1.2" leva: "npm:^0.9.36" lodash: "npm:^4.17.21" nodemon: "npm:^3.0.3" @@ -4434,9 +4436,9 @@ __metadata: languageName: node linkType: hard -"@react-three/drei@npm:^10.6.0": - version: 10.6.0 - resolution: "@react-three/drei@npm:10.6.0" +"@react-three/drei@npm:^10.7.6": + version: 10.7.6 + resolution: "@react-three/drei@npm:10.7.6" dependencies: "@babel/runtime": "npm:^7.26.0" "@mediapipe/tasks-vision": "npm:0.10.17" @@ -4467,7 +4469,7 @@ __metadata: peerDependenciesMeta: react-dom: optional: true - checksum: 10/df42d7e515bde84f1e546e1757c8fdc115d13ee9e6f79e308edc0ecd411c5cfe570c31ebe50b3715e4902432544d9eba204acf760faefca613b31e857ce4fd99 + checksum: 10/fd6efdb687cfa0bdceeaf4183dfad99e7a2dc1b153f803d24de861fea8d5c924d2b01f5a659a4942f051f56842f6ac193f62cc4d1e3d0032f0b847a4c6177ddf languageName: node linkType: hard @@ -5377,6 +5379,13 @@ __metadata: languageName: node linkType: hard +"@types/chroma-js@npm:^3.1.1": + version: 3.1.1 + resolution: "@types/chroma-js@npm:3.1.1" + checksum: 10/24770bd645425a1d90e3651dbb18bc5977290a2d2fc6e9f32d3892d4c43c1e9f554fb324cd0f4e20a23b734ee652278330a0d331973a7da824e0c15192ba129b + languageName: node + linkType: hard + "@types/color-string@npm:^1.5.0": version: 1.5.5 resolution: "@types/color-string@npm:1.5.5" @@ -7840,6 +7849,13 @@ __metadata: languageName: node linkType: hard +"chroma-js@npm:^3.1.2": + version: 3.1.2 + resolution: "chroma-js@npm:3.1.2" + checksum: 10/ea09b27d04b477a8fdc3314bfa6dc8de05feab47999ff1acdeba6cf3c32c76338baa5b4562d949d33d8adeca57754e5ea591c15b9985d65583c8771e7e07c2ef + languageName: node + linkType: hard + "ci-info@npm:^3.2.0": version: 3.9.0 resolution: "ci-info@npm:3.9.0" diff --git a/e2e-pw/src/oss/specs/3d/fo3d-ply.spec.ts-snapshots/ply-scene-top-view-chromium-darwin.png b/e2e-pw/src/oss/specs/3d/fo3d-ply.spec.ts-snapshots/ply-scene-top-view-chromium-darwin.png index dcf074a2f3a..68d6ee9f1b7 100644 Binary files a/e2e-pw/src/oss/specs/3d/fo3d-ply.spec.ts-snapshots/ply-scene-top-view-chromium-darwin.png and b/e2e-pw/src/oss/specs/3d/fo3d-ply.spec.ts-snapshots/ply-scene-top-view-chromium-darwin.png differ diff --git a/e2e-pw/src/oss/specs/3d/fo3d-ply.spec.ts-snapshots/ply-scene-top-view-chromium-linux.png b/e2e-pw/src/oss/specs/3d/fo3d-ply.spec.ts-snapshots/ply-scene-top-view-chromium-linux.png index dcf074a2f3a..68d6ee9f1b7 100644 Binary files a/e2e-pw/src/oss/specs/3d/fo3d-ply.spec.ts-snapshots/ply-scene-top-view-chromium-linux.png and b/e2e-pw/src/oss/specs/3d/fo3d-ply.spec.ts-snapshots/ply-scene-top-view-chromium-linux.png differ