From 6c4273af16128e499e9cd17af735d55d72e99ee7 Mon Sep 17 00:00:00 2001 From: Benjamin Kane Date: Mon, 10 Nov 2025 13:43:03 -0500 Subject: [PATCH 1/4] rm ghost looker for 2D, make lighter atom configurable --- .../src/components/Actions/Selected/Modal.tsx | 6 +- .../src/components/Actions/Selected/hooks.ts | 4 +- .../src/components/Actions/Selected/index.tsx | 2 +- .../Modal/Lighter/LighterSampleRenderer.tsx | 19 +---- .../core/src/components/Modal/ModalLooker.tsx | 19 ++--- .../Modal/Sidebar/Annotate/Annotate.tsx | 5 +- .../Modal/use-modal-selective-rendering.ts | 7 +- app/packages/lighter/src/core/Scene2D.ts | 16 +--- app/packages/lighter/src/core/SceneConfig.ts | 1 - app/packages/lighter/src/react/useLighter.ts | 80 +++++++++++-------- .../lighter/src/react/useLighterSetup.ts | 12 +-- app/packages/lighter/src/state.ts | 4 +- .../looker-3d/src/fo3d/MediaTypeFo3d.tsx | 4 +- 13 files changed, 79 insertions(+), 100 deletions(-) diff --git a/app/packages/core/src/components/Actions/Selected/Modal.tsx b/app/packages/core/src/components/Actions/Selected/Modal.tsx index 860ea5cdc5e..fb83eeb5552 100644 --- a/app/packages/core/src/components/Actions/Selected/Modal.tsx +++ b/app/packages/core/src/components/Actions/Selected/Modal.tsx @@ -25,7 +25,7 @@ export default ({ }: { anchorRef: MutableRefObject; close: () => void; - lookerRef: MutableRefObject; + lookerRef?: MutableRefObject; }) => { const selected = useRecoilValue(fos.selectedSamples); const clearSelection = useClearSampleSelection(close); @@ -34,7 +34,7 @@ export default ({ const isRoot = useRecoilValue(fos.isRootView); const isVideo = useRecoilValue(fos.isVideoDataset) && isRoot; const visibleFrameLabels = - lookerRef.current instanceof VideoLooker + lookerRef?.current instanceof VideoLooker ? lookerRef.current.getCurrentFrameLabels() : new Array(); @@ -45,7 +45,7 @@ export default ({ lookerRef.current.pause(); }); - fos.useEventHandler(lookerRef.current, "play", close); + fos.useEventHandler(lookerRef?.current, "play", close); const closeAndCall = (callback) => { return useCallback(() => { diff --git a/app/packages/core/src/components/Actions/Selected/hooks.ts b/app/packages/core/src/components/Actions/Selected/hooks.ts index 9a77f1a14ef..98346922c36 100644 --- a/app/packages/core/src/components/Actions/Selected/hooks.ts +++ b/app/packages/core/src/components/Actions/Selected/hooks.ts @@ -97,12 +97,12 @@ export const useSelectVisible = ( }; export const useVisibleSampleLabels = ( - lookerRef: MutableRefObject + lookerRef?: MutableRefObject ) => { const isGroup = useRecoilValue(fos.isGroup); const activeLabels = useRecoilValue(fos.activeLabels({})); - const currentSampleLabels = lookerRef.current + const currentSampleLabels = lookerRef?.current ? lookerRef.current.getCurrentSampleLabels() : []; diff --git a/app/packages/core/src/components/Actions/Selected/index.tsx b/app/packages/core/src/components/Actions/Selected/index.tsx index 4c399567e07..ec01e8f2c78 100644 --- a/app/packages/core/src/components/Actions/Selected/index.tsx +++ b/app/packages/core/src/components/Actions/Selected/index.tsx @@ -80,7 +80,7 @@ export default ({ data-cy="action-manage-selected" /> {open && - (modal && lookerRef?.current ? ( + (modal ? ( setOpen(false)} diff --git a/app/packages/core/src/components/Modal/Lighter/LighterSampleRenderer.tsx b/app/packages/core/src/components/Modal/Lighter/LighterSampleRenderer.tsx index 6e311baf45f..d4339563598 100644 --- a/app/packages/core/src/components/Modal/Lighter/LighterSampleRenderer.tsx +++ b/app/packages/core/src/components/Modal/Lighter/LighterSampleRenderer.tsx @@ -31,8 +31,6 @@ export const LighterSampleRenderer = ({ sample, }: LighterSampleRendererProps) => { const containerRef = useRef(null); - // unique scene id allows us to destroy/recreate scenes reliably - const [sceneId, setSceneId] = useState(null); // we have this hack to force a re-render on layout effect, so that containerRef.current is defined // this is to allow stable singleton canvas to bind to new containers @@ -77,14 +75,6 @@ export const LighterSampleRenderer = ({ } }, [isReady, addOverlay, scene]); - useEffect(() => { - // sceneId should be deterministic, but unique for a given sample snapshot - const sample = sampleRef.current; - setSceneId( - `${sample?.sample?._id}-${sample?.sample?.last_modified_at?.datetime}` - ); - }, []); - return (
- {containerRef.current && sceneId && ( - - )} + {containerRef.current && }
); }; const LighterSetupImpl = (props: { containerRef: React.RefObject; - sceneId: string; }) => { - const { containerRef, sceneId } = props; + const { containerRef } = props; const options = useRecoilValue( fos.lookerOptions({ modal: true, withFilter: false }) @@ -117,7 +104,7 @@ const LighterSetupImpl = (props: { const canvas = singletonCanvas.getCanvas(containerRef.current); - const { scene } = useLighterSetupWithPixi(canvas, options, sceneId); + const { scene } = useLighterSetupWithPixi(canvas, options); // This is the bridge between FiftyOne state management system and Lighter useBridge(scene); diff --git a/app/packages/core/src/components/Modal/ModalLooker.tsx b/app/packages/core/src/components/Modal/ModalLooker.tsx index 4ee4a1c642d..99cbb2b36cb 100644 --- a/app/packages/core/src/components/Modal/ModalLooker.tsx +++ b/app/packages/core/src/components/Modal/ModalLooker.tsx @@ -29,12 +29,6 @@ export const useClearSelectedLabels = () => { interface LookerProps { sample: fos.ModalSample; - - // note: this is a hack we're using while migrating to lighter - // a lot of components depend on lighterRef being defined (see `useVisibleSampleLabels` for example) - // we'll remove this once we've migrated to lighter - // `ghost` means looker will render but with width and height set to 0 - ghost?: boolean; } const ModalLookerNoTimeline = React.memo((props: LookerProps) => { @@ -49,8 +43,8 @@ const ModalLookerNoTimeline = React.memo((props: LookerProps) => { id={id} data-cy="modal-looker-container" style={{ - width: props.ghost ? 0 : "100%", - height: props.ghost ? 0 : "100%", + width: "100%", + height: "100%", background: theme.background.level2, position: "relative", }} @@ -91,11 +85,10 @@ export const ModalLooker = React.memo( if ( isNativeMediaType(sample.sample.media_type ?? sample.sample._media_type) ) { - return ( - <> - {mode === "annotate" && } - - + return mode === "annotate" ? ( + + ) : ( + ); } 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 d0842bcb853..ce2581ac9a2 100644 --- a/app/packages/core/src/components/Modal/Sidebar/Annotate/Annotate.tsx +++ b/app/packages/core/src/components/Modal/Sidebar/Annotate/Annotate.tsx @@ -1,10 +1,11 @@ import { LoadingSpinner } from "@fiftyone/components"; -import { lighterSceneAtom } from "@fiftyone/lighter"; +import { defaultLighterSceneAtom } from "@fiftyone/lighter"; import * as fos from "@fiftyone/state"; import { EntryKind } from "@fiftyone/state"; import { isAnnotationSupported } from "@fiftyone/utilities"; 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"; @@ -93,7 +94,7 @@ const Annotate = () => { const showImport = useAtomValue(showImportPage); const loading = useAtomValue(schemas) === null; const editing = useAtomValue(isEditing); - const scene = useAtomValue(lighterSceneAtom); + const scene = useAtomValue(defaultLighterSceneAtom); const mediaType = useRecoilValue(fos.mediaType); const annotationSupported = isAnnotationSupported(mediaType); diff --git a/app/packages/core/src/components/Modal/use-modal-selective-rendering.ts b/app/packages/core/src/components/Modal/use-modal-selective-rendering.ts index 52fec609309..468e4285788 100644 --- a/app/packages/core/src/components/Modal/use-modal-selective-rendering.ts +++ b/app/packages/core/src/components/Modal/use-modal-selective-rendering.ts @@ -13,8 +13,7 @@ import { useDetectNewActiveLabelFields } from "../Sidebar/useDetectNewActiveLabe export const useImageModalSelectiveRendering = ( modalId: string, - looker: Lookers, - ghost?: boolean + looker: Lookers ) => { const { getNewFields } = useDetectNewActiveLabelFields({ modal: true, @@ -26,7 +25,7 @@ export const useImageModalSelectiveRendering = ( )}-image`; useEffect(() => { - if (!looker || ghost) { + if (!looker) { return; } @@ -61,7 +60,7 @@ export const useImageModalSelectiveRendering = ( } looker.updateOptions({}, shouldHardReload); - }, [id, looker, getNewFields, ghost]); + }, [id, looker, getNewFields]); }; export const useImavidModalSelectiveRendering = ( diff --git a/app/packages/lighter/src/core/Scene2D.ts b/app/packages/lighter/src/core/Scene2D.ts index 4be5842df89..5211b37fc0f 100644 --- a/app/packages/lighter/src/core/Scene2D.ts +++ b/app/packages/lighter/src/core/Scene2D.ts @@ -5,21 +5,21 @@ import { AddOverlayCommand } from "../commands/AddOverlayCommand"; import type { Command } from "../commands/Command"; import { - MoveOverlayCommand, type Movable, + MoveOverlayCommand, } from "../commands/MoveOverlayCommand"; import { RemoveOverlayCommand } from "../commands/RemoveOverlayCommand"; import { - TransformOverlayCommand, type TransformOptions, + TransformOverlayCommand, } from "../commands/TransformOverlayCommand"; import { UndoRedoManager } from "../commands/UndoRedoManager"; import { STROKE_WIDTH } from "../constants"; import { DoLighterEvent, LIGHTER_EVENTS, - LighterEventDetail, type LighterEvent, + LighterEventDetail, } from "../event/EventBus"; import type { InteractionHandler } from "../interaction/InteractionManager"; import { InteractionManager } from "../interaction/InteractionManager"; @@ -143,7 +143,6 @@ export class Scene2D { private rotation: number = 0; private interactiveMode: boolean = false; private interactiveHandler?: InteractionHandler; - private readonly sceneId: string | undefined; private abortController = new AbortController(); @@ -160,7 +159,6 @@ export class Scene2D { config.renderer ); this.sceneOptions = config.options; - this.sceneId = config.sceneId; // Listen for scene options changes to trigger re-rendering config.eventBus.on( @@ -1756,12 +1754,4 @@ export class Scene2D { return this.interactiveMode; } - - /** - * Gets the scene ID for this instance. - * @returns Scene ID. - */ - public getSceneId(): string | undefined { - return this.sceneId; - } } diff --git a/app/packages/lighter/src/core/SceneConfig.ts b/app/packages/lighter/src/core/SceneConfig.ts index b30db1d9ff0..1a7016078af 100644 --- a/app/packages/lighter/src/core/SceneConfig.ts +++ b/app/packages/lighter/src/core/SceneConfig.ts @@ -29,5 +29,4 @@ export interface Scene2DConfig { resourceLoader: ResourceLoader; eventBus: EventBus; options?: SceneOptions; - sceneId?: string; } diff --git a/app/packages/lighter/src/react/useLighter.ts b/app/packages/lighter/src/react/useLighter.ts index 56f83c3d5cb..2e579554701 100644 --- a/app/packages/lighter/src/react/useLighter.ts +++ b/app/packages/lighter/src/react/useLighter.ts @@ -6,17 +6,19 @@ import { useAtomValue } from "jotai"; import { useCallback, useEffect, useRef, useState } from "react"; import type { TransformOptions } from "../commands/TransformOverlayCommand"; import type { RenderCallback } from "../core/Scene2D"; -import { lighterSceneAtom, overlayFactory } from "../index"; -import { BaseOverlay } from "../overlay/BaseOverlay"; +import { defaultLighterSceneAtom, overlayFactory } from "../index"; +import type { BaseOverlay } from "../overlay/BaseOverlay"; /** * Hook for accessing the current lighter instance without side effects. * This hook provides access to the lighter scene and its methods from anywhere in the app. * * It's very important to minimize side effects in this hook. + * + * @param atom - The atom in which the scene is stored. */ -export const useLighter = () => { - const scene = useAtomValue(lighterSceneAtom); +export const useLighter = (atom = defaultLighterSceneAtom) => { + const scene = useAtomValue(atom); const isReady = !!scene; const registeredCallbacks = useRef void>>(new Set()); const [canUndo, setCanUndo] = useState(false); @@ -24,10 +26,18 @@ export const useLighter = () => { // Cleanup registered callbacks when scene changes or component unmounts useEffect(() => { + if (!scene) { + return; + } + + const callbacks = registeredCallbacks.current; + return () => { // Unregister all callbacks when component unmounts or scene changes - registeredCallbacks.current.forEach((unregister) => unregister()); - registeredCallbacks.current.clear(); + for (const unregister of callbacks) { + unregister(); + } + callbacks.clear(); }; }, [scene]); @@ -42,6 +52,30 @@ export const useLighter = () => { } }, [scene]); + /** + * Registers a render callback that will be automatically cleaned up when the component unmounts. + * @param callback - The callback configuration. + * @returns A function to manually unregister the callback. + */ + const registerRenderCallback = useCallback( + (callback: Omit & { id?: string }) => { + if (!scene) { + console.warn("Cannot register render callback: scene not ready"); + return () => {}; // Return no-op function + } + + const unregister = scene.registerRenderCallback(callback); + registeredCallbacks.current.add(unregister); + + // Return a function that removes from our tracking and unregisters + return () => { + registeredCallbacks.current.delete(unregister); + unregister(); + }; + }, + [scene] + ); + // Register render callback to update undo/redo state useEffect(() => { if (!scene) return; @@ -53,10 +87,10 @@ export const useLighter = () => { }, phase: "after", }); - }, [scene]); + }, [registerRenderCallback, scene]); const addOverlay = useCallback( - (overlay: BaseOverlay, withUndo: boolean = false) => { + (overlay: BaseOverlay, withUndo = false) => { if (scene) { scene.addOverlay(overlay, withUndo); } @@ -65,7 +99,7 @@ export const useLighter = () => { ); const removeOverlay = useCallback( - (id: string, withUndo: boolean = false) => { + (id: string, withUndo = false) => { if (scene) { scene.removeOverlay(id, withUndo); } @@ -94,41 +128,17 @@ export const useLighter = () => { ); const undo = useCallback(() => { - if (scene && scene.canUndo()) { + if (scene?.canUndo()) { scene.undo(); } }, [scene]); const redo = useCallback(() => { - if (scene && scene.canRedo()) { + if (scene?.canRedo()) { scene.redo(); } }, [scene]); - /** - * Registers a render callback that will be automatically cleaned up when the component unmounts. - * @param callback - The callback configuration. - * @returns A function to manually unregister the callback. - */ - const registerRenderCallback = useCallback( - (callback: Omit & { id?: string }) => { - if (!scene) { - console.warn("Cannot register render callback: scene not ready"); - return () => {}; // Return no-op function - } - - const unregister = scene.registerRenderCallback(callback); - registeredCallbacks.current.add(unregister); - - // Return a function that removes from our tracking and unregisters - return () => { - registeredCallbacks.current.delete(unregister); - unregister(); - }; - }, - [scene] - ); - return { scene, isReady, diff --git a/app/packages/lighter/src/react/useLighterSetup.ts b/app/packages/lighter/src/react/useLighterSetup.ts index be1c8b578ee..d2e7db3d087 100644 --- a/app/packages/lighter/src/react/useLighterSetup.ts +++ b/app/packages/lighter/src/react/useLighterSetup.ts @@ -10,8 +10,8 @@ import { LIGHTER_EVENTS, PixiRenderer2D, Scene2D, + defaultLighterSceneAtom, globalPixiResourceLoader, - lighterSceneAtom, } from "../index"; // TODO: Ultimately, we'll want to remove dependency on "looker" and create our own options type @@ -27,19 +27,20 @@ export type LighterOptions = Partial>; * @param stableCanvas - The canvas element to use for rendering. This should be a stable reference, * i.e., it should not change during the lifetime of the component. * @param options - The options for the scene. + * @param atom - The atom in which the scene is stored. */ export const useLighterSetupWithPixi = ( stableCanvas: HTMLCanvasElement, options: LighterOptions, - sceneId: string + atom = defaultLighterSceneAtom ) => { - const [scene, setScene] = useAtom(lighterSceneAtom); + const [scene, setScene] = useAtom(atom); const rendererRef = useRef(null); const eventBusRef = useRef(null); useEffect(() => { - if (!stableCanvas || !sceneId) return; + if (!stableCanvas) return; const eventBus = new EventBus(); eventBusRef.current = eventBus; @@ -60,12 +61,11 @@ export const useLighterSetupWithPixi = ( canvas: stableCanvas, resourceLoader: globalPixiResourceLoader, options: sceneOptions, - sceneId, }); setScene(newScene); // note: do NOT add options as a dep here, we have another effect to sync scene with new options - }, [sceneId, stableCanvas]); + }, [stableCanvas]); useEffect(() => { if (!scene || scene.isDestroyed) return; diff --git a/app/packages/lighter/src/state.ts b/app/packages/lighter/src/state.ts index db80b195dbc..e8a1f98c2e3 100644 --- a/app/packages/lighter/src/state.ts +++ b/app/packages/lighter/src/state.ts @@ -3,9 +3,9 @@ */ import { atom } from "jotai"; -import { Scene2D } from "./core/Scene2D"; +import type { Scene2D } from "./core/Scene2D"; /** * Atom to store the current lighter scene instance */ -export const lighterSceneAtom = atom(null); +export const defaultLighterSceneAtom = atom(null); diff --git a/app/packages/looker-3d/src/fo3d/MediaTypeFo3d.tsx b/app/packages/looker-3d/src/fo3d/MediaTypeFo3d.tsx index 76e246dc5d6..5900f55080f 100644 --- a/app/packages/looker-3d/src/fo3d/MediaTypeFo3d.tsx +++ b/app/packages/looker-3d/src/fo3d/MediaTypeFo3d.tsx @@ -4,10 +4,10 @@ import { useOverlayPersistence } from "@fiftyone/core/src/components/Modal/Light import useCanAnnotate from "@fiftyone/core/src/components/Modal/Sidebar/Annotate/useCanAnnotate"; import { EventBus, - lighterSceneAtom, MockRenderer2D, MockResourceLoader, Scene2D, + defaultLighterSceneAtom, } from "@fiftyone/lighter"; import { usePluginSettings } from "@fiftyone/plugins"; import * as fos from "@fiftyone/state"; @@ -117,7 +117,7 @@ export const MediaTypeFo3dComponent = () => { return foScene.children?.length ?? 0; }, [foScene]); - const [scene, setScene] = useAtom(lighterSceneAtom); + const [scene, setScene] = useAtom(defaultLighterSceneAtom); // Hack: Setup a ghost lighter for human annotation needs // Todo: Remove this and abstract out event bus / annotaion system from Lighter From e34b877a91177a65e37c57358a6ccdc44e1f907c Mon Sep 17 00:00:00 2001 From: Benjamin Kane Date: Wed, 12 Nov 2025 16:37:36 -0500 Subject: [PATCH 2/4] hide incompatible modal action when annotating --- app/packages/core/src/components/Modal/Actions/index.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/app/packages/core/src/components/Modal/Actions/index.tsx b/app/packages/core/src/components/Modal/Actions/index.tsx index 091759eb6e3..747437370d3 100644 --- a/app/packages/core/src/components/Modal/Actions/index.tsx +++ b/app/packages/core/src/components/Modal/Actions/index.tsx @@ -1,6 +1,7 @@ import { OperatorPlacements, types } from "@fiftyone/operators"; import * as fos from "@fiftyone/state"; import DragIndicatorIcon from "@mui/icons-material/DragIndicator"; +import { useAtomValue } from "jotai"; import React, { useMemo } from "react"; import Draggable from "react-draggable"; import { useRecoilValue } from "recoil"; @@ -87,6 +88,7 @@ export default () => { 0, false ); + const isAnnotating = useAtomValue(fos.modalMode) === "annotate"; return ( { - + {!isAnnotating && } - - + {!isAnnotating && } + {!isAnnotating && } {isGroup && } From ef2ea666e1033d3287da0dee1787a03f414b3400 Mon Sep 17 00:00:00 2001 From: Benjamin Kane Date: Tue, 18 Nov 2025 14:20:54 -0500 Subject: [PATCH 3/4] make lighter canvases reusable --- app/packages/core/package.json | 1 + .../Modal/Lighter/LighterSampleRenderer.tsx | 25 +++-- .../Modal/Lighter/ReusableCanvas.ts | 65 +++++++++++++ .../components/Modal/Lighter/SharedCanvas.ts | 91 ------------------- app/packages/lighter/src/state.ts | 4 +- app/yarn.lock | 3 +- 6 files changed, 87 insertions(+), 102 deletions(-) create mode 100644 app/packages/core/src/components/Modal/Lighter/ReusableCanvas.ts delete mode 100644 app/packages/core/src/components/Modal/Lighter/SharedCanvas.ts diff --git a/app/packages/core/package.json b/app/packages/core/package.json index 9a738a98dd8..ca1126fd39f 100644 --- a/app/packages/core/package.json +++ b/app/packages/core/package.json @@ -12,6 +12,7 @@ "@fiftyone/components": "*", "@fiftyone/feature-flags": "*", "@fiftyone/flashlight": "*", + "@fiftyone/lighter": "*", "@fiftyone/looker": "*", "@fiftyone/map": "*", "@fiftyone/operators": "*", diff --git a/app/packages/core/src/components/Modal/Lighter/LighterSampleRenderer.tsx b/app/packages/core/src/components/Modal/Lighter/LighterSampleRenderer.tsx index d4339563598..5686fb2dcbe 100644 --- a/app/packages/core/src/components/Modal/Lighter/LighterSampleRenderer.tsx +++ b/app/packages/core/src/components/Modal/Lighter/LighterSampleRenderer.tsx @@ -1,9 +1,9 @@ /** * Copyright 2017-2025, Voxel51, Inc. */ +import type { ImageOptions, ImageOverlay, SceneAtom } from "@fiftyone/lighter"; import { - ImageOptions, - ImageOverlay, + defaultLighterSceneAtom, overlayFactory, useLighter, useLighterSetupWithPixi, @@ -11,12 +11,15 @@ import { import type { Sample } from "@fiftyone/state"; import * as fos from "@fiftyone/state"; import { getSampleSrc } from "@fiftyone/state"; +import type { RefObject } from "react"; import React, { useEffect, useLayoutEffect, useRef, useState } from "react"; import { useRecoilValue } from "recoil"; -import { singletonCanvas } from "./SharedCanvas"; +import { defaultCanvas } from "./ReusableCanvas"; import { useBridge } from "./useBridge"; export interface LighterSampleRendererProps { + /** Scene atom */ + atom: SceneAtom; /** Custom CSS class name */ className?: string; /** Sample to display */ @@ -27,6 +30,7 @@ export interface LighterSampleRendererProps { * Lighter unit sample renderer with PixiJS renderer. */ export const LighterSampleRenderer = ({ + atom = defaultLighterSceneAtom, className = "", sample, }: LighterSampleRendererProps) => { @@ -41,7 +45,7 @@ export const LighterSampleRenderer = ({ }, []); // Get access to the lighter instance - const { scene, isReady, addOverlay } = useLighter(); + const { scene, isReady, addOverlay } = useLighter(atom); // use a ref for the sample data, effects do not run solely because the // sample changed @@ -88,23 +92,26 @@ export const LighterSampleRenderer = ({ flexDirection: "column", }} > - {containerRef.current && } + {containerRef.current && ( + + )} ); }; const LighterSetupImpl = (props: { - containerRef: React.RefObject; + atom: SceneAtom; + containerRef: RefObject; }) => { - const { containerRef } = props; + const { atom, containerRef } = props; const options = useRecoilValue( fos.lookerOptions({ modal: true, withFilter: false }) ); - const canvas = singletonCanvas.getCanvas(containerRef.current); + const canvas = defaultCanvas.getCanvas(containerRef.current); - const { scene } = useLighterSetupWithPixi(canvas, options); + const { scene } = useLighterSetupWithPixi(canvas, options, atom); // This is the bridge between FiftyOne state management system and Lighter useBridge(scene); diff --git a/app/packages/core/src/components/Modal/Lighter/ReusableCanvas.ts b/app/packages/core/src/components/Modal/Lighter/ReusableCanvas.ts new file mode 100644 index 00000000000..9595391e04e --- /dev/null +++ b/app/packages/core/src/components/Modal/Lighter/ReusableCanvas.ts @@ -0,0 +1,65 @@ +/** + * Copyright 2017-2025, Voxel51, Inc. + */ + +/** + * A reusable canvas manager that creates and maintains a persistent canvas + * element. + * + * A ReusableCanvas and a SharedPixiApplication allows a single WebGL context + * to be maintained across Lighter scenes. + */ +class ReuseabledCanvas { + private canvas = new HTMLCanvasElement(); + + constructor(id = "default") { + this.canvas.id = `lighter-canvas-${id}`; + this.canvas.setAttribute("data-cy", `lighter-sample-renderer-canvas-${id}`); + this.canvas.style.display = "block"; + this.canvas.style.flex = "1"; + } + + /** + * Get the canvas + * + * If a container is provided, the canvas will be attached to it. + */ + getCanvas(container: null | HTMLElement): HTMLCanvasElement { + if (!container || container === this.canvas.parentNode) { + return this.canvas; + } + + this.canvas.remove(); + container.appendChild(this.canvas); + + return this.canvas; + } + + /** + * Detach the canvas from its current container. + * The canvas element itself is preserved for reuse. + */ + detach(): void { + if (this.isCanvasAttached()) { + this.canvas.remove(); + } + } + + /** + * Get the current canvas element without attaching to a container. + */ + getCanvasElement(): HTMLCanvasElement | null { + return this.canvas; + } + + /** + * Check if the canvas is currently attached to a container. + */ + isCanvasAttached(): boolean { + return !!this.canvas.parentNode; + } +} + +export default ReuseabledCanvas; + +export const defaultCanvas = new ReuseabledCanvas(); diff --git a/app/packages/core/src/components/Modal/Lighter/SharedCanvas.ts b/app/packages/core/src/components/Modal/Lighter/SharedCanvas.ts deleted file mode 100644 index 4e5382b3da0..00000000000 --- a/app/packages/core/src/components/Modal/Lighter/SharedCanvas.ts +++ /dev/null @@ -1,91 +0,0 @@ -/** - * Copyright 2017-2025, Voxel51, Inc. - */ - -/** - * Singleton canvas manager that creates and maintains a persistent canvas element. - * This, together with the SharedPixiApplication, allows us to keep the WebGL context alive and to a single instance. - */ -class SingletonCanvas { - private static instance: SingletonCanvas | null = null; - private canvas: HTMLCanvasElement | null = null; - private container: HTMLElement | null = null; - private isAttached = false; - - private constructor() {} - - static getInstance(): SingletonCanvas { - if (!SingletonCanvas.instance) { - SingletonCanvas.instance = new SingletonCanvas(); - } - return SingletonCanvas.instance; - } - - /** - * Get or create the singleton canvas element. - * If a container is provided, the canvas will be attached to it. - */ - getCanvas(container?: HTMLElement): HTMLCanvasElement { - if (!this.canvas) { - this.canvas = document.createElement("canvas"); - this.canvas.id = "lighter-singleton-canvas"; - this.canvas.setAttribute("data-cy", "lighter-sample-renderer-canvas"); - this.canvas.style.display = "block"; - this.canvas.style.flex = "1"; - } - - if (container && (!this.isAttached || this.container !== container)) { - // Detach from previous container if any - if (this.container && this.canvas.parentNode === this.container) { - this.container.removeChild(this.canvas); - } - - container.appendChild(this.canvas); - this.container = container; - this.isAttached = true; - } - - return this.canvas; - } - - /** - * Detach the canvas from its current container. - * The canvas element itself is preserved for reuse. - */ - detach(): void { - if (this.canvas?.parentNode) { - this.canvas.parentNode.removeChild(this.canvas); - this.isAttached = false; - this.container = null; - } - } - - /** - * Get the current canvas element without attaching to a container. - */ - getCanvasElement(): HTMLCanvasElement | null { - return this.canvas; - } - - /** - * Check if the canvas is currently attached to a container. - */ - isCanvasAttached(): boolean { - return this.isAttached && this.canvas !== null; - } - - /** - * Destroy the singleton canvas and clean up resources. - * Realistically, it's fine for this to never be called. See SharedPixiApplication.destroy(). - */ - destroy(): void { - this.detach(); - if (this.canvas) { - this.canvas = null; - } - this.container = null; - this.isAttached = false; - } -} - -export const singletonCanvas = SingletonCanvas.getInstance(); diff --git a/app/packages/lighter/src/state.ts b/app/packages/lighter/src/state.ts index e8a1f98c2e3..81329602e9e 100644 --- a/app/packages/lighter/src/state.ts +++ b/app/packages/lighter/src/state.ts @@ -6,6 +6,8 @@ import { atom } from "jotai"; import type { Scene2D } from "./core/Scene2D"; /** - * Atom to store the current lighter scene instance + * Atom to store the default lighter scene instance */ export const defaultLighterSceneAtom = atom(null); + +export type SceneAtom = typeof defaultLighterSceneAtom; diff --git a/app/yarn.lock b/app/yarn.lock index 14ad4dedf05..0b865dbfa98 100644 --- a/app/yarn.lock +++ b/app/yarn.lock @@ -2452,6 +2452,7 @@ __metadata: "@fiftyone/components": "npm:*" "@fiftyone/feature-flags": "npm:*" "@fiftyone/flashlight": "npm:*" + "@fiftyone/lighter": "npm:*" "@fiftyone/looker": "npm:*" "@fiftyone/map": "npm:*" "@fiftyone/operators": "npm:*" @@ -2637,7 +2638,7 @@ __metadata: languageName: unknown linkType: soft -"@fiftyone/lighter@workspace:packages/lighter": +"@fiftyone/lighter@npm:*, @fiftyone/lighter@workspace:packages/lighter": version: 0.0.0-use.local resolution: "@fiftyone/lighter@workspace:packages/lighter" dependencies: From af1aa054dd2aec496e4f5d9f7c53ccb4ade7581d Mon Sep 17 00:00:00 2001 From: Benjamin Kane Date: Tue, 18 Nov 2025 14:35:14 -0500 Subject: [PATCH 4/4] cleanup --- .../components/Modal/Lighter/ReusableCanvas.ts | 17 +++++++++-------- app/packages/lighter/src/react/useLighter.ts | 1 + 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/app/packages/core/src/components/Modal/Lighter/ReusableCanvas.ts b/app/packages/core/src/components/Modal/Lighter/ReusableCanvas.ts index 9595391e04e..4257db68349 100644 --- a/app/packages/core/src/components/Modal/Lighter/ReusableCanvas.ts +++ b/app/packages/core/src/components/Modal/Lighter/ReusableCanvas.ts @@ -9,10 +9,11 @@ * A ReusableCanvas and a SharedPixiApplication allows a single WebGL context * to be maintained across Lighter scenes. */ -class ReuseabledCanvas { - private canvas = new HTMLCanvasElement(); +class ReusabledCanvas { + private canvas: HTMLCanvasElement; constructor(id = "default") { + this.canvas = document.createElement("canvas"); this.canvas.id = `lighter-canvas-${id}`; this.canvas.setAttribute("data-cy", `lighter-sample-renderer-canvas-${id}`); this.canvas.style.display = "block"; @@ -24,7 +25,7 @@ class ReuseabledCanvas { * * If a container is provided, the canvas will be attached to it. */ - getCanvas(container: null | HTMLElement): HTMLCanvasElement { + getCanvas(container: null | HTMLElement) { if (!container || container === this.canvas.parentNode) { return this.canvas; } @@ -39,7 +40,7 @@ class ReuseabledCanvas { * Detach the canvas from its current container. * The canvas element itself is preserved for reuse. */ - detach(): void { + detach() { if (this.isCanvasAttached()) { this.canvas.remove(); } @@ -48,18 +49,18 @@ class ReuseabledCanvas { /** * Get the current canvas element without attaching to a container. */ - getCanvasElement(): HTMLCanvasElement | null { + getCanvasElement() { return this.canvas; } /** * Check if the canvas is currently attached to a container. */ - isCanvasAttached(): boolean { + isCanvasAttached() { return !!this.canvas.parentNode; } } -export default ReuseabledCanvas; +export default ReusabledCanvas; -export const defaultCanvas = new ReuseabledCanvas(); +export const defaultCanvas = new ReusabledCanvas(); diff --git a/app/packages/lighter/src/react/useLighter.ts b/app/packages/lighter/src/react/useLighter.ts index 2e579554701..090982544c8 100644 --- a/app/packages/lighter/src/react/useLighter.ts +++ b/app/packages/lighter/src/react/useLighter.ts @@ -30,6 +30,7 @@ export const useLighter = (atom = defaultLighterSceneAtom) => { return; } + // assign referenced callbacks when the effect runs const callbacks = registeredCallbacks.current; return () => {