tr]:last:border-b-0', className)}
+ {...props}
+ />
+ );
}
-function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
- return (
-
- )
+function TableRow({ className, ...props }: React.ComponentProps<'tr'>) {
+ return (
+
+ );
}
-function TableHead({ className, ...props }: React.ComponentProps<"th">) {
- return (
- [role=checkbox]]:translate-y-[2px]",
- className
- )}
- {...props}
- />
- )
+function TableHead({ className, ...props }: React.ComponentProps<'th'>) {
+ return (
+ | [role=checkbox]]:translate-y-[2px]',
+ className
+ )}
+ {...props}
+ />
+ );
}
-function TableCell({ className, ...props }: React.ComponentProps<"td">) {
- return (
- | [role=checkbox]]:translate-y-[2px]",
- className
- )}
- {...props}
- />
- )
+function TableCell({ className, ...props }: React.ComponentProps<'td'>) {
+ return (
+ | [role=checkbox]]:translate-y-[2px]',
+ className
+ )}
+ {...props}
+ />
+ );
}
-function TableCaption({
- className,
- ...props
-}: React.ComponentProps<"caption">) {
- return (
-
- )
+function TableCaption({ className, ...props }: React.ComponentProps<'caption'>) {
+ return (
+
+ );
}
-export {
- Table,
- TableHeader,
- TableBody,
- TableFooter,
- TableHead,
- TableRow,
- TableCell,
- TableCaption,
-}
+export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };
diff --git a/packages/blocks/src/components/ui/tabs.tsx b/packages/blocks/src/components/ui/tabs.tsx
index 76bebf20..f2dcc9e1 100644
--- a/packages/blocks/src/components/ui/tabs.tsx
+++ b/packages/blocks/src/components/ui/tabs.tsx
@@ -1,66 +1,44 @@
-"use client"
+'use client';
-import * as React from "react"
-import * as TabsPrimitive from "@radix-ui/react-tabs"
+import * as TabsPrimitive from '@radix-ui/react-tabs';
+import * as React from 'react';
-import { cn } from "@lib/utils"
+import { cn } from '@lib/utils';
-function Tabs({
- className,
- ...props
-}: React.ComponentProps) {
- return (
-
- )
+function Tabs({ className, ...props }: React.ComponentProps) {
+ return ;
}
-function TabsList({
- className,
- ...props
-}: React.ComponentProps) {
- return (
-
- )
+function TabsList({ className, ...props }: React.ComponentProps) {
+ return (
+
+ );
}
-function TabsTrigger({
- className,
- ...props
-}: React.ComponentProps) {
- return (
-
- )
+function TabsTrigger({ className, ...props }: React.ComponentProps) {
+ return (
+
+ );
}
-function TabsContent({
- className,
- ...props
-}: React.ComponentProps) {
- return (
-
- )
+function TabsContent({ className, ...props }: React.ComponentProps) {
+ return (
+
+ );
}
-export { Tabs, TabsList, TabsTrigger, TabsContent }
+export { Tabs, TabsList, TabsTrigger, TabsContent };
diff --git a/packages/blocks/src/components/ui/toggle-group.tsx b/packages/blocks/src/components/ui/toggle-group.tsx
index 05893856..ecd4bcc1 100644
--- a/packages/blocks/src/components/ui/toggle-group.tsx
+++ b/packages/blocks/src/components/ui/toggle-group.tsx
@@ -1,73 +1,67 @@
-"use client"
+'use client';
-import * as React from "react"
-import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
-import { type VariantProps } from "class-variance-authority"
+import * as ToggleGroupPrimitive from '@radix-ui/react-toggle-group';
+import type { VariantProps } from 'class-variance-authority';
+import * as React from 'react';
-import { cn } from "@lib/utils"
-import { toggleVariants } from "@components/ui/toggle"
+import { toggleVariants } from '@components/ui/toggle';
+import { cn } from '@lib/utils';
-const ToggleGroupContext = React.createContext<
- VariantProps
->({
- size: "default",
- variant: "default",
-})
+const ToggleGroupContext = React.createContext>({
+ size: 'default',
+ variant: 'default'
+});
function ToggleGroup({
- className,
- variant,
- size,
- children,
- ...props
-}: React.ComponentProps &
- VariantProps) {
- return (
-
-
- {children}
-
-
- )
+ className,
+ variant,
+ size,
+ children,
+ ...props
+}: React.ComponentProps & VariantProps) {
+ return (
+
+ {children}
+
+ );
}
function ToggleGroupItem({
- className,
- children,
- variant,
- size,
- ...props
-}: React.ComponentProps &
- VariantProps) {
- const context = React.useContext(ToggleGroupContext)
+ className,
+ children,
+ variant,
+ size,
+ ...props
+}: React.ComponentProps & VariantProps) {
+ const context = React.useContext(ToggleGroupContext);
- return (
-
- {children}
-
- )
+ return (
+
+ {children}
+
+ );
}
-export { ToggleGroup, ToggleGroupItem }
+export { ToggleGroup, ToggleGroupItem };
diff --git a/packages/blocks/src/components/ui/toggle.tsx b/packages/blocks/src/components/ui/toggle.tsx
index 316c973a..9873f8ba 100644
--- a/packages/blocks/src/components/ui/toggle.tsx
+++ b/packages/blocks/src/components/ui/toggle.tsx
@@ -1,47 +1,46 @@
-"use client"
+'use client';
-import * as React from "react"
-import * as TogglePrimitive from "@radix-ui/react-toggle"
-import { cva, type VariantProps } from "class-variance-authority"
+import * as TogglePrimitive from '@radix-ui/react-toggle';
+import { cva } from 'class-variance-authority';
+import type { VariantProps } from 'class-variance-authority';
+import * as React from 'react';
-import { cn } from "@lib/utils"
+import { cn } from '@lib/utils';
const toggleVariants = cva(
- "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
- {
- variants: {
- variant: {
- default: "bg-transparent",
- outline:
- "border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
- },
- size: {
- default: "h-9 px-2 min-w-9",
- sm: "h-8 px-1.5 min-w-8",
- lg: "h-10 px-2.5 min-w-10",
- },
- },
- defaultVariants: {
- variant: "default",
- size: "default",
- },
- }
-)
+ "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
+ {
+ variants: {
+ variant: {
+ default: 'bg-transparent',
+ outline: 'border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground'
+ },
+ size: {
+ default: 'h-9 px-2 min-w-9',
+ sm: 'h-8 px-1.5 min-w-8',
+ lg: 'h-10 px-2.5 min-w-10'
+ }
+ },
+ defaultVariants: {
+ variant: 'default',
+ size: 'default'
+ }
+ }
+);
function Toggle({
- className,
- variant,
- size,
- ...props
-}: React.ComponentProps &
- VariantProps) {
- return (
-
- )
+ className,
+ variant,
+ size,
+ ...props
+}: React.ComponentProps & VariantProps) {
+ return (
+
+ );
}
-export { Toggle, toggleVariants }
+export { Toggle, toggleVariants };
diff --git a/packages/blocks/src/components/ui/tooltip.tsx b/packages/blocks/src/components/ui/tooltip.tsx
index c0622b18..7ab25841 100644
--- a/packages/blocks/src/components/ui/tooltip.tsx
+++ b/packages/blocks/src/components/ui/tooltip.tsx
@@ -1,60 +1,47 @@
-"use client"
+'use client';
-import * as React from "react"
-import * as TooltipPrimitive from "@radix-ui/react-tooltip"
+import * as TooltipPrimitive from '@radix-ui/react-tooltip';
+import * as React from 'react';
-import { cn } from "@lib/utils"
+import { cn } from '@lib/utils';
-function TooltipProvider({
- delayDuration = 0,
- ...props
-}: React.ComponentProps) {
- return (
-
- )
+function TooltipProvider({ delayDuration = 0, ...props }: React.ComponentProps) {
+ return ;
}
-function Tooltip({
- ...props
-}: React.ComponentProps) {
- return (
-
-
-
- )
+function Tooltip({ ...props }: React.ComponentProps) {
+ return (
+
+
+
+ );
}
-function TooltipTrigger({
- ...props
-}: React.ComponentProps) {
- return
+function TooltipTrigger({ ...props }: React.ComponentProps) {
+ return ;
}
function TooltipContent({
- className,
- sideOffset = 0,
- children,
- ...props
+ className,
+ sideOffset = 0,
+ children,
+ ...props
}: React.ComponentProps) {
- return (
-
-
- {children}
-
-
- )
+ return (
+
+
+ {children}
+
+
+ );
}
-export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
+export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
diff --git a/packages/blocks/src/index.css b/packages/blocks/src/index.css
index 84e62a7c..4c704648 100644
--- a/packages/blocks/src/index.css
+++ b/packages/blocks/src/index.css
@@ -1,5 +1,5 @@
-@import "tailwindcss";
-@import "tw-animate-css";
+@import 'tailwindcss';
+@import 'tw-animate-css';
@custom-variant dark (&:is(.dark *));
@@ -74,9 +74,9 @@
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.705 0.015 286.067);
- }
-
- .dark {
+}
+
+.dark {
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
@@ -108,11 +108,10 @@
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.552 0.016 285.938);
- }
-
+}
@layer base {
- * {
- @apply border-border outline-ring/50;
- }
+ * {
+ @apply border-border outline-ring/50;
+ }
}
diff --git a/packages/blocks/src/index.ts b/packages/blocks/src/index.ts
index 58d3f5c0..68dde325 100644
--- a/packages/blocks/src/index.ts
+++ b/packages/blocks/src/index.ts
@@ -1,4 +1,4 @@
// Splat Viewer
-import * as Viewer from "./splat-viewer/index.ts"
-import { useAssetViewer } from "./splat-viewer/splat-viewer-context.ts";
+import * as Viewer from './splat-viewer/index.ts';
+import { useAssetViewer } from './splat-viewer/splat-viewer-context.tsx';
export { Viewer, useAssetViewer };
diff --git a/packages/blocks/src/lib/utils.ts b/packages/blocks/src/lib/utils.ts
index bd0c391d..66bf229d 100644
--- a/packages/blocks/src/lib/utils.ts
+++ b/packages/blocks/src/lib/utils.ts
@@ -1,6 +1,7 @@
-import { clsx, type ClassValue } from "clsx"
-import { twMerge } from "tailwind-merge"
+import { clsx } from 'clsx';
+import type { ClassValue } from 'clsx';
+import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
- return twMerge(clsx(inputs))
+ return twMerge(clsx(inputs));
}
diff --git a/packages/blocks/src/splat-viewer/camera-mode-toggle.tsx b/packages/blocks/src/splat-viewer/camera-mode-toggle.tsx
index 9bbd45c4..cbe7a445 100644
--- a/packages/blocks/src/splat-viewer/camera-mode-toggle.tsx
+++ b/packages/blocks/src/splat-viewer/camera-mode-toggle.tsx
@@ -1,32 +1,30 @@
-"use client"
+'use client';
-import { Move3D, Rotate3D } from "lucide-react";
-import { useAssetViewer } from "./splat-viewer-context.tsx";
-import { Button } from "@components/ui/button";
-import { Tooltip, TooltipContent, TooltipTrigger } from "@components/ui/tooltip";
+import { Move3D, Rotate3D } from 'lucide-react';
-function CameraModeToggle() {
+import { Button } from '@components/ui/button';
+import { Tooltip, TooltipContent, TooltipTrigger } from '@components/ui/tooltip';
+
+import { useAssetViewer } from './splat-viewer-context.tsx';
+function CameraModeToggle() {
const { mode, setMode } = useAssetViewer();
-
- return (
-
-
-
-
- {mode === 'orbit' ? "Fly Camera" : "Orbit Camera"}
-
- )
- }
-export { CameraModeToggle };
\ No newline at end of file
+ return (
+
+
+
+
+ {mode === 'orbit' ? 'Fly Camera' : 'Orbit Camera'}
+
+ );
+}
+
+export { CameraModeToggle };
diff --git a/packages/blocks/src/splat-viewer/controls.tsx b/packages/blocks/src/splat-viewer/controls.tsx
index 17d0b00e..fefdca8a 100644
--- a/packages/blocks/src/splat-viewer/controls.tsx
+++ b/packages/blocks/src/splat-viewer/controls.tsx
@@ -1,40 +1,40 @@
+'use client';
-"use client"
+import { cn } from '@lib/utils';
-import { useAssetViewer, useTimeline } from "./splat-viewer-context.tsx";
-import { cn } from "@lib/utils";
+import { useAssetViewer, useTimeline } from './splat-viewer-context.tsx';
type ControlsProps = {
/**
* The className of the controls
*/
- className?: string,
+ className?: string;
/**
* When enabled, the controls will be hidden when the user is not interacting with the asset.
* @defaultValue false
*/
- autoHide?: boolean,
+ autoHide?: boolean;
/**
* The children of the controls
*/
- children: React.ReactNode,
-}
+ children: React.ReactNode;
+};
export function Controls({ className, autoHide = false, children }: ControlsProps) {
- const { isInteracting } = useAssetViewer();
- const { isPlaying } = useTimeline();
+ const { isInteracting } = useAssetViewer();
+ const { isPlaying } = useTimeline();
- return (
-
- {children}
-
- );
-}
\ No newline at end of file
+ return (
+
+ {children}
+
+ );
+}
diff --git a/packages/blocks/src/splat-viewer/download-button.tsx b/packages/blocks/src/splat-viewer/download-button.tsx
index 499dc944..29b6f7ae 100644
--- a/packages/blocks/src/splat-viewer/download-button.tsx
+++ b/packages/blocks/src/splat-viewer/download-button.tsx
@@ -1,36 +1,36 @@
-"use client";
+'use client';
-import { DownloadIcon } from "lucide-react";
-import { Button } from "@components/ui/button";
-import { useAssetViewer } from "./splat-viewer-context.tsx";
-import { TooltipTrigger, TooltipContent, Tooltip } from "@components/ui/tooltip";
+import { DownloadIcon } from 'lucide-react';
+
+import { Button } from '@components/ui/button';
+import { TooltipTrigger, TooltipContent, Tooltip } from '@components/ui/tooltip';
+
+import { useAssetViewer } from './splat-viewer-context.tsx';
type DownloadButtonProps = {
- variant?: "default" | "outline" | "ghost" | "link";
-}
+ variant?: 'default' | 'outline' | 'ghost' | 'link';
+};
-function DownloadButton({ variant = "ghost" }: DownloadButtonProps) {
- const { src, triggerDownload } = useAssetViewer(); // assume src is a URL string
+function DownloadButton({ variant = 'ghost' }: DownloadButtonProps) {
+ const { src, triggerDownload } = useAssetViewer(); // assume src is a URL string
- return (
-
-
-
-
-
- Download
-
-
- );
+ return (
+
+
+
+
+ Download
+
+ );
}
-export { DownloadButton };
\ No newline at end of file
+export { DownloadButton };
diff --git a/packages/blocks/src/splat-viewer/full-screen-button.tsx b/packages/blocks/src/splat-viewer/full-screen-button.tsx
index 8ce89571..bfc3a95b 100644
--- a/packages/blocks/src/splat-viewer/full-screen-button.tsx
+++ b/packages/blocks/src/splat-viewer/full-screen-button.tsx
@@ -1,31 +1,38 @@
-"use client";
+'use client';
-import { MaximizeIcon, MinimizeIcon } from "lucide-react";
-import { Button } from "@components/ui/button";
-import { useAssetViewer } from "./splat-viewer-context.tsx";
-import { TooltipTrigger, TooltipContent, Tooltip } from "@components/ui/tooltip";
+import { MaximizeIcon, MinimizeIcon } from 'lucide-react';
+
+import { Button } from '@components/ui/button';
+import { TooltipTrigger, TooltipContent, Tooltip } from '@components/ui/tooltip';
+
+import { useAssetViewer } from './splat-viewer-context.tsx';
const FullScreenToggleIcon = ({ isFullscreen }: { isFullscreen: boolean }) => {
return isFullscreen ? : ;
-}
+};
type FullScreenButtonProps = {
- variant?: "default" | "outline" | "ghost" | "link";
-}
+ variant?: 'default' | 'outline' | 'ghost' | 'link';
+};
-function FullScreenButton({ variant = "ghost" }: FullScreenButtonProps) {
+function FullScreenButton({ variant = 'ghost' }: FullScreenButtonProps) {
const { isFullscreen, toggleFullscreen } = useAssetViewer();
- return (
-
-
-
-
- {isFullscreen ? "Exit Fullscreen" : "Enter Fullscreen"}
-
- );
+ return (
+
+
+
+
+ {isFullscreen ? 'Exit Fullscreen' : 'Enter Fullscreen'}
+
+ );
}
-export { FullScreenButton };
\ No newline at end of file
+export { FullScreenButton };
diff --git a/packages/blocks/src/splat-viewer/help-button.tsx b/packages/blocks/src/splat-viewer/help-button.tsx
index 9c05cff1..a4916be9 100644
--- a/packages/blocks/src/splat-viewer/help-button.tsx
+++ b/packages/blocks/src/splat-viewer/help-button.tsx
@@ -1,17 +1,22 @@
-"use client"
+'use client';
-import { Button } from "@components/ui/button"
-import { useAssetViewer } from "./splat-viewer-context.tsx"
+import { Button } from '@components/ui/button';
-function HelpButton() {
+import { useAssetViewer } from './splat-viewer-context.tsx';
- const { setOverlay } = useAssetViewer()
+function HelpButton() {
+ const { setOverlay } = useAssetViewer();
- return (
-
- )
+ return (
+
+ );
}
-export { HelpButton };
\ No newline at end of file
+export { HelpButton };
diff --git a/packages/blocks/src/splat-viewer/help-dialog.tsx b/packages/blocks/src/splat-viewer/help-dialog.tsx
index e3c6a994..a2ffea67 100644
--- a/packages/blocks/src/splat-viewer/help-dialog.tsx
+++ b/packages/blocks/src/splat-viewer/help-dialog.tsx
@@ -1,165 +1,207 @@
-import * as React from "react"
-import { useAssetViewer } from "./splat-viewer-context.ts"
-import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@components/ui/dialog"
-import { Drawer, DrawerClose, DrawerContent, DrawerDescription, DrawerFooter, DrawerHeader, DrawerTitle } from "@components/ui/drawer"
-import { Table, TableHeader, TableBody, TableCell, TableRow, TableHead } from "@components/ui/table"
-import { Badge } from "@components/ui/badge"
-import { TabsContent, TabsList, TabsTrigger } from "@components/ui/tabs"
-import { Tabs } from "@components/ui/tabs"
-import { CameraModeToggle } from "./menu-button.tsx"
-import { Card } from "@components/ui/card"
+import * as React from 'react';
+
+import { Badge } from '@components/ui/badge';
+import { Card } from '@components/ui/card';
+import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@components/ui/dialog';
+import {
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle
+} from '@components/ui/drawer';
+import { Table, TableHeader, TableBody, TableCell, TableRow, TableHead } from '@components/ui/table';
+import { TabsContent, TabsList, TabsTrigger, Tabs } from '@components/ui/tabs';
+
+import { CameraModeToggle } from './menu-button.tsx';
+import { useAssetViewer } from './splat-viewer-context.tsx';
export function useMediaQuery(query: string) {
- const [value, setValue] = React.useState(false)
+ const [value, setValue] = React.useState(false);
- React.useEffect(() => {
- function onChange(event: MediaQueryListEvent) {
- setValue(event.matches)
- }
+ React.useEffect(() => {
+ function onChange(event: MediaQueryListEvent) {
+ setValue(event.matches);
+ }
- const result = matchMedia(query)
- result.addEventListener("change", onChange)
- setValue(result.matches)
+ const result = matchMedia(query);
+ result.addEventListener('change', onChange);
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- initialize from the subscribed media query
+ setValue(result.matches);
- return () => result.removeEventListener("change", onChange)
- }, [query])
+ return () => result.removeEventListener('change', onChange);
+ }, [query]);
- return value
+ return value;
}
-
+
function CameraHelpTable() {
- const { mode } = useAssetViewer()
+ const { mode } = useAssetViewer();
- return (
- <>
-
- { mode === "orbit" ? (
-
- R
- ],
- ]} />
- ) : (
-
- W
- S
- ],
- ["Left / Right",
- A
- D
- ],
- ["Up / Down",
- Q
- E
- ],
- ["Reset View",
- R
- ],
- ]} />
- )}
-
-
- { mode === "orbit" ? () : (
-
- )}
-
- >
- )
+ return (
+ <>
+
+ {mode === 'orbit' ? (
+
+ R
+
+ ]
+ ]}
+ />
+ ) : (
+
+ W
+ S
+
+ ],
+ [
+ 'Left / Right',
+
+ A
+ D
+
+ ],
+ [
+ 'Up / Down',
+
+ Q
+ E
+
+ ],
+ [
+ 'Reset View',
+
+ R
+
+ ]
+ ]}
+ />
+ )}
+
+
+ {mode === 'orbit' ? (
+
+ ) : (
+
+ )}
+
+ >
+ );
}
-
+
function ControlSection({ controls }: { controls: [string, string | React.ReactNode][] }) {
- return (
-
-
-
- Input
- Action
-
-
-
- {controls.map(([label, key]) => (
-
- {label}
- {key}
-
- ))}
-
-
- )
+ return (
+
+
+
+ Input
+ Action
+
+
+
+ {controls.map(([label, key]) => (
+
+ {label}
+ {key}
+
+ ))}
+
+
+ );
}
-
+
export function HelpDialog() {
- const { overlay, setOverlay } = useAssetViewer()
- const isDesktop = useMediaQuery("(min-width: 768px)")
+ const { overlay, setOverlay } = useAssetViewer();
+ const isDesktop = useMediaQuery('(min-width: 768px)');
+
+ if (isDesktop) {
+ return (
+
+ );
+ }
- if (isDesktop) {
return (
-
- )
- }
-
- return (
- setOverlay(null)}
- shouldScaleBackground={true}
- setBackgroundColorOnScale={true}
- >
-
-
-
- Controls
- Use these controls to navigate.
-
-
-
-
-
-
-
- Keyboard
- Touch
-
-
-
-
-
-
-
-
-
-
-
- )
-}
\ No newline at end of file
+ setOverlay(null)}
+ shouldScaleBackground={true}
+ setBackgroundColorOnScale={true}
+ >
+
+
+
+ Controls
+ Use these controls to navigate.
+
+
+
+
+
+
+
+
+ Keyboard
+
+
+ Touch
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/packages/blocks/src/splat-viewer/hooks/use-render-on-camera-change.tsx b/packages/blocks/src/splat-viewer/hooks/use-render-on-camera-change.tsx
index 3175dbae..303875a8 100644
--- a/packages/blocks/src/splat-viewer/hooks/use-render-on-camera-change.tsx
+++ b/packages/blocks/src/splat-viewer/hooks/use-render-on-camera-change.tsx
@@ -1,12 +1,12 @@
-import { useApp, useAppEvent } from "@playcanvas/react/hooks";
-import { Entity as PcEntity } from "playcanvas";
-import { useEffect, useRef } from "react";
+import { useApp, useAppEvent } from '@playcanvas/react/hooks';
+import type { Entity as PcEntity } from 'playcanvas';
+import { useEffect, useRef } from 'react';
const nearlyEquals = (a: Float32Array, b: Float32Array, epsilon = 1e-4): boolean => {
- for (let i = 0; i < a.length; i++) {
- if (Math.abs(a[i] - b[i]) >= epsilon) return false;
- }
- return true;
+ for (let i = 0; i < a.length; i++) {
+ if (Math.abs(a[i] - b[i]) >= epsilon) return false;
+ }
+ return true;
};
/**
@@ -17,59 +17,61 @@ const nearlyEquals = (a: Float32Array, b: Float32Array, epsilon = 1e-4): boolean
* @returns {void} This hook does not return a value but updates the rendering state of the application.
*/
export const useRenderOnCameraChange = (entity: PcEntity | null) => {
- const app = useApp();
- const prevWorld = useRef(new Float32Array(16));
- const prevProj = useRef(new Float32Array(16));
- const isVisible = useRef(true);
+ const app = useApp();
+ const prevWorld = useRef(new Float32Array(16));
+ const prevProj = useRef(new Float32Array(16));
+ const isVisible = useRef(true);
- /**
- * This hook ensures that rendering only happens when the canvas is visible in the viewport.
- * This takes precedence over the camera change detection. Eg if the camera is animating, but
- * the canvas is not visible, dont render.
- */
+ /**
+ * This hook ensures that rendering only happens when the canvas is visible in the viewport.
+ * This takes precedence over the camera change detection. Eg if the camera is animating, but
+ * the canvas is not visible, dont render.
+ */
- useEffect(() => {
- const canvas = app.graphicsDevice.canvas;
- if (!canvas) return;
+ useEffect(() => {
+ const canvas = app.graphicsDevice.canvas;
+ if (!canvas) return;
- const observer = new IntersectionObserver(
- ([entry]) => {
- isVisible.current = entry.isIntersecting;
- if (!isVisible.current) {
- app.renderNextFrame = false;
- }
- },
- { threshold: 0.1 }
- );
+ const observer = new IntersectionObserver(
+ ([entry]) => {
+ isVisible.current = entry.isIntersecting;
+ if (!isVisible.current) {
+ app.renderNextFrame = false;
+ }
+ },
+ { threshold: 0.1 }
+ );
- observer.observe(canvas);
- return () => observer.disconnect();
- }, [app]);
+ observer.observe(canvas);
+ return () => observer.disconnect();
+ }, [app]);
- /**
- * This hook ensures that rendering only happens when the camera has moved or its projection has changed.
- * However if the canvas is not visible on the page it will take precedence.
- * Don't render if the canvas is not visible regardless of any animations.
- */
- useAppEvent('update', () => {
- if (!entity || !isVisible.current) return;
- const world = entity.getWorldTransform().data;
- const proj = entity.camera?.projectionMatrix?.data;
+ /**
+ * This hook ensures that rendering only happens when the camera has moved or its projection has changed.
+ * However if the canvas is not visible on the page it will take precedence.
+ * Don't render if the canvas is not visible regardless of any animations.
+ */
+ /* eslint-disable react-hooks/immutability -- the engine update callback intentionally controls rendering */
+ useAppEvent('update', () => {
+ if (!entity || !isVisible.current) return;
+ const world = entity.getWorldTransform().data;
+ const proj = entity.camera?.projectionMatrix?.data;
- if (!proj) {
- app.renderNextFrame = true;
- return;
- }
+ if (!proj) {
+ app.renderNextFrame = true;
+ return;
+ }
- const changed = !nearlyEquals(world, prevWorld.current) || !nearlyEquals(proj, prevProj.current);
-
- if (!app.renderNextFrame) {
- app.renderNextFrame = changed;
- }
+ const changed = !nearlyEquals(world, prevWorld.current) || !nearlyEquals(proj, prevProj.current);
- if (app.renderNextFrame) {
- prevWorld.current.set(world);
- prevProj.current.set(proj);
- }
- });
-};
\ No newline at end of file
+ if (!app.renderNextFrame) {
+ app.renderNextFrame = changed;
+ }
+
+ if (app.renderNextFrame) {
+ prevWorld.current.set(world);
+ prevProj.current.set(proj);
+ }
+ });
+ /* eslint-enable react-hooks/immutability */
+};
diff --git a/packages/blocks/src/splat-viewer/hooks/use-subscribe.ts b/packages/blocks/src/splat-viewer/hooks/use-subscribe.ts
index 23309e27..b9e3e325 100644
--- a/packages/blocks/src/splat-viewer/hooks/use-subscribe.ts
+++ b/packages/blocks/src/splat-viewer/hooks/use-subscribe.ts
@@ -9,20 +9,20 @@ export type NotifyFn = (value: T) => void;
* const { subscribe, notify } = useSubscribe();
* subscribe((value) => console.log(value));
* notify(1);
- *
+ *
* @returns `subscribe` and `notify` functions.
*/
-export function useSubscribe() : [SubscribeFn, NotifyFn] {
- const subscribers = useRef(new Set<(value: T) => void>());
+export function useSubscribe(): [SubscribeFn, NotifyFn] {
+ const subscribers = useRef(new Set<(value: T) => void>());
- const subscribe: SubscribeFn = useCallback((fn: (value: T) => void) => {
- subscribers.current.add(fn);
- return () => subscribers.current.delete(fn);
- }, []);
+ const subscribe: SubscribeFn = useCallback((fn: (value: T) => void) => {
+ subscribers.current.add(fn);
+ return () => subscribers.current.delete(fn);
+ }, []);
- const notify: NotifyFn = useCallback((value: T) => {
- subscribers.current.forEach((fn) => fn(value));
- }, []);
+ const notify: NotifyFn = useCallback((value: T) => {
+ subscribers.current.forEach((fn) => fn(value));
+ }, []);
- return [subscribe, notify];
-}
\ No newline at end of file
+ return [subscribe, notify];
+}
diff --git a/packages/blocks/src/splat-viewer/index.ts b/packages/blocks/src/splat-viewer/index.ts
index 8d02287a..5199bcf3 100644
--- a/packages/blocks/src/splat-viewer/index.ts
+++ b/packages/blocks/src/splat-viewer/index.ts
@@ -1,19 +1,19 @@
-import { FullScreenButton } from "./full-screen-button.tsx"
-import { DownloadButton } from "./download-button.tsx"
-import { MenuButton } from "./menu-button.tsx"
-import { Controls } from "./controls.tsx"
-import { CameraModeToggle } from "./camera-mode-toggle.tsx"
-import { HelpButton } from "./help-button.tsx"
-import { SplatViewer } from "./splat-viewer.tsx"
-import { Progress } from "./progress-indicator.tsx"
+import { CameraModeToggle } from './camera-mode-toggle.tsx';
+import { Controls } from './controls.tsx';
+import { DownloadButton } from './download-button.tsx';
+import { FullScreenButton } from './full-screen-button.tsx';
+import { HelpButton } from './help-button.tsx';
+import { MenuButton } from './menu-button.tsx';
+import { Progress } from './progress-indicator.tsx';
+import { SplatViewer } from './splat-viewer.tsx';
export {
- FullScreenButton,
- DownloadButton,
- MenuButton,
- Controls,
- CameraModeToggle,
- HelpButton,
- SplatViewer as Splat,
- Progress,
-};
\ No newline at end of file
+ FullScreenButton,
+ DownloadButton,
+ MenuButton,
+ Controls,
+ CameraModeToggle,
+ HelpButton,
+ SplatViewer as Splat,
+ Progress
+};
diff --git a/packages/blocks/src/splat-viewer/menu-button.tsx b/packages/blocks/src/splat-viewer/menu-button.tsx
index 4b246161..749af669 100644
--- a/packages/blocks/src/splat-viewer/menu-button.tsx
+++ b/packages/blocks/src/splat-viewer/menu-button.tsx
@@ -1,17 +1,47 @@
-"use client";
+'use client';
-import { Button } from "@components/ui/button";
-import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuTrigger } from "@components/ui/dropdown-menu";
-import { useMediaQuery } from "./help-dialog.tsx";
-import { EllipsisVerticalIcon, DownloadIcon, MinimizeIcon, MaximizeIcon, HelpCircleIcon, Rotate3dIcon, Move3DIcon, RotateCcwIcon } from "lucide-react";
-import { Drawer, DrawerContent, DrawerDescription, DrawerFooter, DrawerHeader, DrawerTitle, DrawerClose } from "@components/ui/drawer";
-import { useAssetViewer } from "./splat-viewer-context.ts";
-import { ToggleGroupItem } from "@components/ui/toggle-group";
-import { ToggleGroup } from "@components/ui/toggle-group";
-import { Card } from "@components/ui/card";
-import { Switch } from "@components/ui/switch";
-import { Separator } from "@components/ui/separator";
-import { CameraMode } from "./splat-viewer.tsx";
+import {
+ EllipsisVerticalIcon,
+ DownloadIcon,
+ MinimizeIcon,
+ MaximizeIcon,
+ HelpCircleIcon,
+ Rotate3dIcon,
+ Move3DIcon,
+ RotateCcwIcon
+} from 'lucide-react';
+
+import { Button } from '@components/ui/button';
+import { Card } from '@components/ui/card';
+import {
+ Drawer,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+ DrawerClose
+} from '@components/ui/drawer';
+import {
+ DropdownMenu,
+ DropdownMenuCheckboxItem,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuTrigger
+} from '@components/ui/dropdown-menu';
+import { Separator } from '@components/ui/separator';
+import { Switch } from '@components/ui/switch';
+import { ToggleGroupItem, ToggleGroup } from '@components/ui/toggle-group';
+
+import { useMediaQuery } from './help-dialog.tsx';
+import { useAssetViewer } from './splat-viewer-context.tsx';
+import type { CameraMode } from './splat-viewer.tsx';
function MenuItemsDesktop({
mode,
setMode,
@@ -19,71 +49,68 @@ function MenuItemsDesktop({
triggerDownload,
toggleFullscreen,
resetCamera
- }: ReturnType) {
+}: ReturnType) {
return (
- <>
-
- Settings
-
- setOverlay("help")}>
- Help
- ⇧F
-
- triggerDownload()}>
- Download
- ⇧⌘D
-
- toggleFullscreen()}>
- Fullscreen
- ⇧⌘F
-
- resetCamera()}>
- Reset View
- R
-
-
- Auto Rotate
- ⇧⌘R
-
-
-
-
-
- Controls
- setMode(value as CameraMode)}>
-
- Orbit
- ⌘O
-
-
- Fly
- ⌘F
-
-
-
- >
- )
- }
+ <>
+
+ Settings
+
+ setOverlay('help')}>
+ Help
+ ⇧F
+
+ triggerDownload()}>
+ Download
+ ⇧⌘D
+
+ toggleFullscreen()}>
+ Fullscreen
+ ⇧⌘F
+
+ resetCamera()}>
+ Reset View
+ R
+
+
+ Auto Rotate
+ ⇧⌘R
+
+
+
+
+
+ Controls
+ setMode(value as CameraMode)}>
+
+ Orbit
+ ⌘O
+
+
+ Fly
+ ⌘F
+
+
+
+ >
+ );
+}
- export function CameraModeToggle() {
+export function CameraModeToggle() {
const { mode, setMode } = useAssetViewer();
const safeSetMode = (mode: string) => {
- if (mode === "orbit" || mode === "fly") {
+ if (mode === 'orbit' || mode === 'fly') {
setMode(mode);
}
- }
+ };
return (
Camera mode
- { mode === "orbit" && Great for inspecting an object. }
- { mode === "fly" && Ideal for navigating larger scenes. }
+ {mode === 'orbit' && Great for inspecting an object.}
+ {mode === 'fly' && Ideal for navigating larger scenes.}
@@ -95,10 +122,10 @@ function MenuItemsDesktop({
- )
- }
+ );
+}
- function MenuItemsMobile({
+function MenuItemsMobile({
setOverlay,
triggerDownload,
toggleFullscreen,
@@ -106,117 +133,140 @@ function MenuItemsDesktop({
autoRotate,
setAutoRotate,
resetCamera
- }: ReturnType) {
+}: ReturnType) {
return (
-
-
-
-
-
+
+
+
+
- {/* Auto rotate */}
-
-
-
- Auto rotate
-
- { autoRotate
- ? Automatically rotate the camera around the object.
- : Disable automatic rotation.
- }
-
+ {/* Auto rotate */}
+
+
+
+ Auto rotate
+
+ {autoRotate ? (
+ Automatically rotate the camera around the object.
+ ) : (
+ Disable automatic rotation.
+ )}
+
+
+ {
+ setAutoRotate(!autoRotate);
+ setOverlay(null);
+ }}
+ />
- {
- setAutoRotate(!autoRotate);
- setOverlay(null);
- }}/>
-
-
+
+
+
-
-
-
- Settings
-
-
-
-
+
+ Settings
+
+
+
+
+
-
- )
- }
+ );
+}
function MenuButton() {
+ const isDesktop = useMediaQuery('(min-width: 768px)');
+ const props = useAssetViewer();
+ const { overlay, setOverlay } = props;
- const isDesktop = useMediaQuery("(min-width: 768px)");
- const props = useAssetViewer();
- const {overlay, setOverlay} = props
-
if (isDesktop) {
return (
- )
+ );
}
return (
- <>
-
- setOverlay(null)} shouldScaleBackground={true} setBackgroundColorOnScale={true}>
-
-
-
- Settings
- Viewer options and controls.
-
-
-
-
-
-
-
-
- >
- )
+ <>
+
+ setOverlay(null)}
+ shouldScaleBackground={true}
+ setBackgroundColorOnScale={true}
+ >
+
+
+
+ Settings
+ Viewer options and controls.
+
+
+
+
+
+
+
+
+ >
+ );
}
-export { MenuButton }
\ No newline at end of file
+export { MenuButton };
diff --git a/packages/blocks/src/splat-viewer/progress-indicator.tsx b/packages/blocks/src/splat-viewer/progress-indicator.tsx
index d60e875e..be0de29c 100644
--- a/packages/blocks/src/splat-viewer/progress-indicator.tsx
+++ b/packages/blocks/src/splat-viewer/progress-indicator.tsx
@@ -1,51 +1,53 @@
-"use client";
+'use client';
-import { useEffect, useRef, useState } from "react";
-import { useAssetViewer } from "./splat-viewer-context.ts";
-import { cn } from "@lib/utils";
+import { useEffect, useRef, useState } from 'react';
+
+import { cn } from '@lib/utils';
+
+import { useAssetViewer } from './splat-viewer-context.tsx';
type ProgressProps = {
- variant?: "top" | "bottom";
- className?: string;
- style?: React.CSSProperties;
+ variant?: 'top' | 'bottom';
+ className?: string;
+ style?: React.CSSProperties;
};
-export function Progress({ variant = "top", className, style }: ProgressProps) {
- const { subscribe } = useAssetViewer();
- const ref = useRef(null);
- const [visible, setVisible] = useState(true);
- const timeoutRef = useRef | null>(null);
-
- useEffect(() => {
- const unsubscribe = subscribe((progress) => {
- if (!ref.current) return;
- ref.current.style.width = `${progress * 100}%`;
-
- if (progress >= 1) {
- timeoutRef.current = setTimeout(() => setVisible(false), 500);
- } else {
- setVisible(true);
- }
- });
-
- return () => {
- unsubscribe();
- if (timeoutRef.current) clearTimeout(timeoutRef.current);
- };
- }, [subscribe]);
-
- if (!visible) return null;
-
- return (
-
- );
-}
\ No newline at end of file
+export function Progress({ variant = 'top', className, style }: ProgressProps) {
+ const { subscribe } = useAssetViewer();
+ const ref = useRef(null);
+ const [visible, setVisible] = useState(true);
+ const timeoutRef = useRef | null>(null);
+
+ useEffect(() => {
+ const unsubscribe = subscribe((progress) => {
+ if (!ref.current) return;
+ ref.current.style.width = `${progress * 100}%`;
+
+ if (progress >= 1) {
+ timeoutRef.current = setTimeout(() => setVisible(false), 500);
+ } else {
+ setVisible(true);
+ }
+ });
+
+ return () => {
+ unsubscribe();
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
+ };
+ }, [subscribe]);
+
+ if (!visible) return null;
+
+ return (
+
+ );
+}
diff --git a/packages/blocks/src/splat-viewer/smart-camera.tsx b/packages/blocks/src/splat-viewer/smart-camera.tsx
index 6f04fd44..3f33e678 100644
--- a/packages/blocks/src/splat-viewer/smart-camera.tsx
+++ b/packages/blocks/src/splat-viewer/smart-camera.tsx
@@ -1,46 +1,45 @@
-"use client";
-
-import { useEffect, useRef, useState } from "react";
-import { Entity } from "@playcanvas/react";
-import { Camera, Script } from "@playcanvas/react/components";
-import { useTimeline, useAssetViewer } from "./splat-viewer-context.ts";
-import { Vec3, Entity as PcEntity } from "playcanvas";
-
-import { AnimationTrack, AnimCamera, createRotationTrack } from "./utils/animation.ts"; // assumed
-import { computeStartingPose, Pose, PoseType } from "./utils/pose.ts";
-import { useApp, useParent } from "@playcanvas/react/hooks";
-import { PostEffectsSettings, StaticPostEffects } from "./utils/effects.ts";
-import { paris, neutral, noir } from "./utils/style.ts";
-
-// @ts-expect-error There is no type definition for the camera-controls script
-import { CameraControls } from "playcanvas/scripts/esm/camera-controls.mjs";
-import { useRenderOnCameraChange } from "./hooks/use-render-on-camera-change.ts";
+'use client';
+
+import { Entity } from '@playcanvas/react';
+import { Camera, Script } from '@playcanvas/react/components';
+import { useApp, useParent } from '@playcanvas/react/hooks';
+import type { Entity as PcEntity } from 'playcanvas';
+import { Vec3 } from 'playcanvas';
+// @ts-expect-error there is no type definition for the camera-controls script
+import { CameraControls } from 'playcanvas/scripts/esm/camera-controls.mjs';
+import { useEffect, useRef, useState } from 'react';
+
+import { useRenderOnCameraChange } from './hooks/use-render-on-camera-change.tsx';
+import { useTimeline, useAssetViewer } from './splat-viewer-context.tsx';
+import type { AnimationTrack } from './utils/animation.ts';
+import { AnimCamera, createRotationTrack } from './utils/animation.ts'; // assumed
+import type { PostEffectsSettings } from './utils/effects.tsx';
+import { StaticPostEffects } from './utils/effects.tsx';
+import type { PoseType } from './utils/pose.ts';
+import { computeStartingPose, Pose } from './utils/pose.ts';
+import { paris, neutral, noir } from './utils/style.ts';
const variants = new Map([
- ['paris', paris],
- ['neutral', neutral],
- ['noir', noir]
+ ['paris', paris],
+ ['neutral', neutral],
+ ['noir', noir]
]);
-const length = (a: [number, number, number], b: [number, number, number]) => Math.sqrt(
- Math.pow(a[0] - b[0], 2) +
- Math.pow(a[1] - b[1], 2) +
- Math.pow(a[2] - b[2], 2)
-)
+const length = (a: [number, number, number], b: [number, number, number]) =>
+ Math.sqrt(Math.pow(a[0] - b[0], 2) + Math.pow(a[1] - b[1], 2) + Math.pow(a[2] - b[2], 2));
type CameraControlsProps = {
/* The focus point of the camera */
- focus?: [number, number, number]
- enablePan: boolean,
- enableFly: boolean,
- enableOrbit: boolean,
- enabled?: boolean,
- distance?: number,
- animate?: boolean,
-}
+ focus?: [number, number, number];
+ enablePan: boolean;
+ enableFly: boolean;
+ enableOrbit: boolean;
+ enabled?: boolean;
+ distance?: number;
+ animate?: boolean;
+};
function CameraController({ focus = [0, 0, 0], distance = 0, animate = false, ...props }: CameraControlsProps) {
-
const entity = useParent();
useEffect(() => {
@@ -53,161 +52,164 @@ function CameraController({ focus = [0, 0, 0], distance = 0, animate = false, ..
const controls = (entity.script?.cameraControls || entity.script?._CameraControls) as CameraControls;
if (controls) {
controls.focus(new Vec3().fromArray(focus), false);
-
}
}, [focus, distance, animate]);
- return (<>
-
- >);
+ return (
+ <>
+
+ >
+ );
}
export function SmartCamera({
- fov = 30,
- animationTrack,
- variant = "neutral"
+ fov = 30,
+ animationTrack,
+ variant = 'neutral'
}: {
- fov?: number;
- animationTrack?: AnimationTrack;
- variant?: "paris" | "neutral" | "noir" | "none" | PostEffectsSettings;
+ fov?: number;
+ animationTrack?: AnimationTrack;
+ variant?: 'paris' | 'neutral' | 'noir' | 'none' | PostEffectsSettings;
}) {
+ const entityRef = useRef(null);
+ const { subscribe, isPlaying } = useTimeline();
+ const { mode, subscribeCameraReset } = useAssetViewer();
+ const timeoutRef = useRef | null>(null);
+ const [shouldUseRenderOnCameraChange, setShouldUseRenderOnCameraChange] = useState(false);
+ const app = useApp();
+ const initialPoseRef = useRef(null);
- const entityRef = useRef(null);
- const { subscribe, isPlaying } = useTimeline();
- const { mode, subscribeCameraReset } = useAssetViewer();
- const timeoutRef = useRef | null>(null);
- const [shouldUseRenderOnCameraChange, setShouldUseRenderOnCameraChange] = useState(false);
- const app = useApp();
- const initialPoseRef = useRef(null);
-
- useEffect(() => {
- timeoutRef.current = setTimeout(() => {
- setShouldUseRenderOnCameraChange(true);
- app.renderNextFrame = true;
- }, 200);
- return () => clearTimeout(timeoutRef.current!);
- });
-
- useRenderOnCameraChange(shouldUseRenderOnCameraChange ? entityRef.current : null);
-
- const [pose, setPose] = useState({
- position: [2, 1, 2],
- target: [0, 0, 0]
- });
-
- const [animation, setAnimation] = useState(animationTrack ? AnimCamera.fromTrack(animationTrack) : null);
-
- useEffect(() => {
-
- const initialPose = computeStartingPose(app, fov);
- initialPoseRef.current = initialPose;
-
- setPose(initialPose);
- if(!animation) {
- const actualPose = new Pose().fromLookAt(new Vec3().fromArray(initialPose.position), new Vec3().fromArray(initialPose.target));
- const track = createRotationTrack(actualPose)
- setAnimation(track);
- }
-
- }, [app]);
-
- // Expose reset functionality through callback
- useEffect(() => {
-
- if (!subscribeCameraReset) return;
-
- const unsubscribe = subscribeCameraReset(() => {
- if (!initialPoseRef.current || !entityRef.current) return;
-
- setPose(computeStartingPose(app, fov));
-
- // Force a render
- app.renderNextFrame = true;
+ useEffect(() => {
+ timeoutRef.current = setTimeout(() => {
+ setShouldUseRenderOnCameraChange(true);
+ app.renderNextFrame = true;
+ }, 200);
+ return () => clearTimeout(timeoutRef.current!);
});
- return unsubscribe;
- }, [subscribeCameraReset]);
-
- // Listen to timeline changes
- useEffect(() => {
-
- if (!animation || !isPlaying) return;
-
- const pose = new Pose();
- // const rot = new Quat();
- const unsub = subscribe((t) => {
- animation.cursor.value = t * animation.frameRate;
- animation.update();
- animation.getPose(pose);
-
- // if (mode === "animation") {
- entityRef.current?.setPosition(animation.position);
- entityRef.current?.setRotation(pose.rotation);
-
- // } else if (mode === "transition") {
- // // blend from previous to this
- // const { pos: fromPos, rot: fromRot } = transitionStartRef.current!;
- // const currentPos = new Vec3().lerp(fromPos, pose, 0.1);
- // const currentRot = new Quat().slerp(fromRot, rot, 0.1);
-
- // entityRef.current?.setPosition(currentPos);
- // entityRef.current?.setRotation(currentRot);
-
- // if (
- // currentPos.distance(pose) < 0.01 &&
- // Quat.dot(currentRot, rot) > 0.999
- // ) {
- // setMode("animation");
- // }
- // }
+ // eslint-disable-next-line react-hooks/refs -- the camera hook consumes the existing imperative entity ref
+ useRenderOnCameraChange(shouldUseRenderOnCameraChange ? entityRef.current : null);
+
+ const [pose, setPose] = useState({
+ position: [2, 1, 2],
+ target: [0, 0, 0]
});
- return unsub;
- }, [mode, animation, isPlaying]);
-
- // When timeline starts playing, begin transition
-// useEffect(() => {
-// if (isPlaying && mode === "interactive" && animation) {
-// transitionStartRef.current = {
-// pos: entityRef.current?.getPosition().clone(),
-// rot: entityRef.current?.getRotation().clone(),
-// };
-// setMode("transition");
-// }
-// }, [isPlaying]);
-
-// if (!animationTrack || mode === "interactive") {
-// return (
-//
-//
-//
-//
-//
-// );
-// }
-
- // If the variant is a string, use the default variant, otherwise use the variant object passed in
- const style = typeof variant === 'string' ? variants.get(variant) ?? neutral : variant;
-
- const distance = length(pose.position, pose.target) * 0.5
-
- return (
-
-
-
- {variant !== 'none' && }
-
- );
+ const [animation, setAnimation] = useState(
+ animationTrack ? AnimCamera.fromTrack(animationTrack) : null
+ );
+
+ useEffect(() => {
+ const initialPose = computeStartingPose(app, fov);
+ initialPoseRef.current = initialPose;
+
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- the engine-derived pose initializes camera state
+ setPose(initialPose);
+ if (!animation) {
+ const actualPose = new Pose().fromLookAt(
+ new Vec3().fromArray(initialPose.position),
+ new Vec3().fromArray(initialPose.target)
+ );
+ const track = createRotationTrack(actualPose);
+ setAnimation(track);
+ }
+ }, [app]);
+
+ // Expose reset functionality through callback
+ useEffect(() => {
+ if (!subscribeCameraReset) return;
+
+ const unsubscribe = subscribeCameraReset(() => {
+ if (!initialPoseRef.current || !entityRef.current) return;
+
+ setPose(computeStartingPose(app, fov));
+
+ // Force a render
+ app.renderNextFrame = true;
+ });
+
+ return unsubscribe;
+ }, [subscribeCameraReset]);
+
+ // Listen to timeline changes
+ useEffect(() => {
+ if (!animation || !isPlaying) return;
+
+ const pose = new Pose();
+ // const rot = new Quat();
+ const unsub = subscribe((t) => {
+ animation.cursor.value = t * animation.frameRate;
+ animation.update();
+ animation.getPose(pose);
+
+ // if (mode === "animation") {
+ entityRef.current?.setPosition(animation.position);
+ entityRef.current?.setRotation(pose.rotation);
+
+ // } else if (mode === "transition") {
+ // // blend from previous to this
+ // const { pos: fromPos, rot: fromRot } = transitionStartRef.current!;
+ // const currentPos = new Vec3().lerp(fromPos, pose, 0.1);
+ // const currentRot = new Quat().slerp(fromRot, rot, 0.1);
+
+ // entityRef.current?.setPosition(currentPos);
+ // entityRef.current?.setRotation(currentRot);
+
+ // if (
+ // currentPos.distance(pose) < 0.01 &&
+ // Quat.dot(currentRot, rot) > 0.999
+ // ) {
+ // setMode("animation");
+ // }
+ // }
+ });
+
+ return unsub;
+ }, [mode, animation, isPlaying]);
+
+ // When timeline starts playing, begin transition
+ // useEffect(() => {
+ // if (isPlaying && mode === "interactive" && animation) {
+ // transitionStartRef.current = {
+ // pos: entityRef.current?.getPosition().clone(),
+ // rot: entityRef.current?.getRotation().clone(),
+ // };
+ // setMode("transition");
+ // }
+ // }, [isPlaying]);
+
+ // if (!animationTrack || mode === "interactive") {
+ // return (
+ //
+ //
+ //
+ //
+ //
+ // );
+ // }
+
+ // If the variant is a string, use the default variant, otherwise use the variant object passed in
+ const style = typeof variant === 'string' ? (variants.get(variant) ?? neutral) : variant;
+
+ const distance = length(pose.position, pose.target) * 0.5;
+
+ return (
+
+
+
+ {variant !== 'none' && }
+
+ );
}
diff --git a/packages/blocks/src/splat-viewer/splat-viewer-context.tsx b/packages/blocks/src/splat-viewer/splat-viewer-context.tsx
index aeddb584..f2164194 100644
--- a/packages/blocks/src/splat-viewer/splat-viewer-context.tsx
+++ b/packages/blocks/src/splat-viewer/splat-viewer-context.tsx
@@ -1,339 +1,339 @@
-"use client";
+'use client';
-import { createContext, useCallback, useEffect, useState, useContext, ReactNode, useRef } from "react";
-import { CameraMode } from "./splat-viewer.tsx";
-import { useSubscribe } from "./hooks/use-subscribe.tsx";
+import type { ReactNode } from 'react';
+import { createContext, useCallback, useEffect, useState, useContext, useRef } from 'react';
+
+import { useSubscribe } from './hooks/use-subscribe.ts';
+import type { CameraMode } from './splat-viewer.tsx';
type AssetViewerContextValue = {
- /**
- * Whether the viewer is in fullscreen mode.
- */
- isFullscreen: boolean;
-
- /**
- * Toggles the fullscreen mode.
- */
- toggleFullscreen: () => void;
-
- /**
- * Triggers a download of the asset.
- */
- triggerDownload: () => void;
-
- /**
- * The source of the asset.
- */
- src: string | Record;
-
- /**
- * Whether the viewer is interacting with the asset.
- */
- isInteracting: boolean;
-
- /**
- * The type of camera to use.
- */
- mode: CameraMode;
-
- /**
- * The type of camera to use.
- */
- setMode: (mode: CameraMode) => void;
-
- /**
- * The overlay to display.
- * @defaultValue null
- */
- overlay: "help" | "settings" | null;
-
- /**
- * Toggles the overlay.
- */
- setOverlay: (overlay: "help" | "settings" | null) => void;
-
- /**
- * Whether the auto rotate is enabled.
- */
- autoRotate: boolean;
-
- /**
- * Toggles the auto rotate.
- */
- setAutoRotate: (autoRotate: boolean) => void;
-
- /**
- * Subscribes to the asset progress.
- */
- subscribe: (fn: (progress: number) => void) => () => void;
-
- /**
- * Subscribes to the camera reset.
- */
- subscribeCameraReset: (fn: () => void) => () => void;
-
- /**
- * Resets the camera.
- */
- resetCamera: () => void;
+ /**
+ * Whether the viewer is in fullscreen mode.
+ */
+ isFullscreen: boolean;
+
+ /**
+ * Toggles the fullscreen mode.
+ */
+ toggleFullscreen: () => void;
+
+ /**
+ * Triggers a download of the asset.
+ */
+ triggerDownload: () => void;
+
+ /**
+ * The source of the asset.
+ */
+ src: string | Record;
+
+ /**
+ * Whether the viewer is interacting with the asset.
+ */
+ isInteracting: boolean;
+
+ /**
+ * The type of camera to use.
+ */
+ mode: CameraMode;
+
+ /**
+ * The type of camera to use.
+ */
+ setMode: (mode: CameraMode) => void;
+
+ /**
+ * The overlay to display.
+ * @defaultValue null
+ */
+ overlay: 'help' | 'settings' | null;
+
+ /**
+ * Toggles the overlay.
+ */
+ setOverlay: (overlay: 'help' | 'settings' | null) => void;
+
+ /**
+ * Whether the auto rotate is enabled.
+ */
+ autoRotate: boolean;
+
+ /**
+ * Toggles the auto rotate.
+ */
+ setAutoRotate: (autoRotate: boolean) => void;
+
+ /**
+ * Subscribes to the asset progress.
+ */
+ subscribe: (fn: (progress: number) => void) => () => void;
+
+ /**
+ * Subscribes to the camera reset.
+ */
+ subscribeCameraReset: (fn: () => void) => () => void;
+
+ /**
+ * Resets the camera.
+ */
+ resetCamera: () => void;
};
export const AssetViewerContext = createContext(undefined);
export function useAssetViewer() {
- const ctx = useContext(AssetViewerContext);
- if (!ctx) throw new Error("useAssetViewer must be used within an AssetViewerProvider");
- return ctx;
+ const ctx = useContext(AssetViewerContext);
+ if (!ctx) throw new Error('useAssetViewer must be used within an AssetViewerProvider');
+ return ctx;
}
function isMacPlatform(): boolean {
- // @ts-expect-error this is not available on all browsers
- const userAgentData = navigator.userAgentData;
- if (userAgentData?.platform) {
- return userAgentData.platform === "macOS";
- }
-
- // Fallback for older browsers
- return /Mac/i.test(navigator.userAgent || "");
+ // @ts-expect-error this is not available on all browsers
+ const userAgentData = navigator.userAgentData;
+ if (userAgentData?.platform) {
+ return userAgentData.platform === 'macOS';
+ }
+
+ // Fallback for older browsers
+ return /Mac/i.test(navigator.userAgent || '');
}
export function AssetViewerProvider({
- children,
- autoPlay = false,
- targetRef,
- mode,
- setMode,
- src,
- subscribe
+ children,
+ autoPlay = false,
+ targetRef,
+ mode,
+ setMode,
+ src,
+ subscribe
}: {
- children: React.ReactNode;
- autoPlay?: boolean;
- targetRef: React.RefObject;
- src: string | Record;
- mode: CameraMode;
- setMode: (mode: CameraMode) => void;
- subscribe: (fn: (progress: number) => void) => () => void;
+ children: React.ReactNode;
+ autoPlay?: boolean;
+ targetRef: React.RefObject;
+ src: string | Record;
+ mode: CameraMode;
+ setMode: (mode: CameraMode) => void;
+ subscribe: (fn: (progress: number) => void) => () => void;
}) {
-
- const [isFullscreen, setIsFullscreen] = useState(false);
- const [overlay, setOverlay] = useState<"help" | "settings" | null>(null);
- const [autoRotate, setAutoRotate] = useState(false);
-
- // download
- const triggerDownload = useCallback(() => {
- if (typeof src === 'string') {
- // Single file download
- const link = document.createElement('a');
- link.href = src;
- link.download = src.split('/').pop() || 'splat.ply';
- document.body.appendChild(link);
- link.click();
- document.body.removeChild(link);
- } else {
- // Multiple file download for SogsMeta
- const files = Object.values(src).reduce((acc, value) => {
- if (value && typeof value === 'object' && 'files' in value) {
- return [...acc, ...(value.files as string[])];
+ const [isFullscreen, setIsFullscreen] = useState(false);
+ const [overlay, setOverlay] = useState<'help' | 'settings' | null>(null);
+ const [autoRotate, setAutoRotate] = useState(false);
+
+ // download
+ const triggerDownload = useCallback(() => {
+ if (typeof src === 'string') {
+ // Single file download
+ const link = document.createElement('a');
+ link.href = src;
+ link.download = src.split('/').pop() || 'splat.ply';
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ } else {
+ // Multiple file download for SogsMeta
+ const files = Object.values(src).reduce((acc, value) => {
+ if (value && typeof value === 'object' && 'files' in value) {
+ return [...acc, ...(value.files as string[])];
+ }
+ return acc;
+ }, []);
+
+ // Download each file sequentially
+ files.forEach((file, index) => {
+ const link = document.createElement('a');
+ link.href = file;
+ link.download = file.split('/').pop() || `splat_${index}.texture`;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ });
}
- return acc;
- }, []);
-
- // Download each file sequentially
- files.forEach((file, index) => {
- const link = document.createElement('a');
- link.href = file;
- link.download = file.split('/').pop() || `splat_${index}.texture`;
- document.body.appendChild(link);
- link.click();
- document.body.removeChild(link);
- });
- }
- }, [src]);
-
- // subscribe to the camera reset event
- const [subscribeCameraReset, notifyCameraReset] = useSubscribe();
-
- const resetCamera = useCallback(() => {
- notifyCameraReset();
- setMode('orbit');
- setOverlay(null);
- }, [notifyCameraReset, setMode, setOverlay]);
-
- // Listen for keyboard events
- const handleKey = useCallback((e: KeyboardEvent) => {
- const isCmdOrCtrl = isMacPlatform() ? e.metaKey : e.ctrlKey;
-
- if (e.key === "?" && e.shiftKey) setOverlay('help') // help
- if (e.key.toLowerCase() === "f" && e.shiftKey && isCmdOrCtrl) toggleFullscreen() // fullscreen
- if (e.key.toLowerCase() === "d" && e.shiftKey && isCmdOrCtrl) triggerDownload() // download
- if (e.key.toLowerCase() === "r") resetCamera() // reset camera
- }, [])
-
- useEffect(() => {
- window.addEventListener("keydown", handleKey)
- return () => window.removeEventListener("keydown", handleKey)
- }, [handleKey])
-
- // toggle fullscreen
- const toggleFullscreen = useCallback(async () => {
- const el = targetRef.current;
- if (!el) return;
-
- if (document.fullscreenElement) {
- await document.exitFullscreen();
- setOverlay(null)
- setIsFullscreen(false);
- } else {
- await el.requestFullscreen();
- setOverlay(null)
- setIsFullscreen(true);
- }
- }, [targetRef]);
-
- // handle fullscreen state
- useEffect(() => {
- const handler = () => setIsFullscreen(!!document.fullscreenElement);
- document.addEventListener("fullscreenchange", handler);
- return () => document.removeEventListener("fullscreenchange", handler);
- }, []);
-
- // handle interaction state
- const [isInteracting, setIsInteracting] = useState(false);
- const isInteractingRef = useRef(false);
- const timeoutRef = useRef | null>(null);
-
- useEffect(() => {
- const el = targetRef.current;
- if (!el) return;
-
- const onInteract = () => {
- if (!isInteractingRef.current) {
- isInteractingRef.current = true;
- setIsInteracting(true);
- }
-
- clearTimeout(timeoutRef.current!);
- timeoutRef.current = setTimeout(() => {
- isInteractingRef.current = false;
- setIsInteracting(false);
- }, 2000);
- };
-
- el.addEventListener("mousemove", onInteract);
- el.addEventListener("keydown", onInteract);
- el.addEventListener("pointerdown", onInteract);
-
- return () => {
- clearTimeout(timeoutRef.current!);
- timeoutRef.current = null;
- el.removeEventListener("mousemove", onInteract);
- el.removeEventListener("keydown", onInteract);
- el.removeEventListener("pointerdown", onInteract);
- };
- }, [targetRef]);
-
- return (
-
-
- {children}
-
-
- );
+ }, [src]);
+
+ // subscribe to the camera reset event
+ const [subscribeCameraReset, notifyCameraReset] = useSubscribe();
+
+ const resetCamera = useCallback(() => {
+ notifyCameraReset();
+ setMode('orbit');
+ setOverlay(null);
+ }, [notifyCameraReset, setMode, setOverlay]);
+
+ // Listen for keyboard events
+ /* eslint-disable react-hooks/immutability -- the event callback preserves the existing later callback binding */
+ const handleKey = useCallback((e: KeyboardEvent) => {
+ const isCmdOrCtrl = isMacPlatform() ? e.metaKey : e.ctrlKey;
+
+ if (e.key === '?' && e.shiftKey) setOverlay('help'); // help
+ if (e.key.toLowerCase() === 'f' && e.shiftKey && isCmdOrCtrl) toggleFullscreen(); // fullscreen
+ if (e.key.toLowerCase() === 'd' && e.shiftKey && isCmdOrCtrl) triggerDownload(); // download
+ if (e.key.toLowerCase() === 'r') resetCamera(); // reset camera
+ }, []);
+ /* eslint-enable react-hooks/immutability */
+
+ useEffect(() => {
+ window.addEventListener('keydown', handleKey);
+ return () => window.removeEventListener('keydown', handleKey);
+ }, [handleKey]);
+
+ // toggle fullscreen
+ // eslint-disable-next-line react-hooks/preserve-manual-memoization -- the target ref intentionally keys this callback
+ const toggleFullscreen = useCallback(async () => {
+ const el = targetRef.current;
+ if (!el) return;
+
+ if (document.fullscreenElement) {
+ await document.exitFullscreen();
+ setOverlay(null);
+ setIsFullscreen(false);
+ } else {
+ await el.requestFullscreen();
+ setOverlay(null);
+ setIsFullscreen(true);
+ }
+ }, [targetRef]);
+
+ // handle fullscreen state
+ useEffect(() => {
+ const handler = () => setIsFullscreen(!!document.fullscreenElement);
+ document.addEventListener('fullscreenchange', handler);
+ return () => document.removeEventListener('fullscreenchange', handler);
+ }, []);
+
+ // handle interaction state
+ const [isInteracting, setIsInteracting] = useState(false);
+ const isInteractingRef = useRef(false);
+ const timeoutRef = useRef | null>(null);
+
+ useEffect(() => {
+ const el = targetRef.current;
+ if (!el) return;
+
+ const onInteract = () => {
+ if (!isInteractingRef.current) {
+ isInteractingRef.current = true;
+ setIsInteracting(true);
+ }
+
+ clearTimeout(timeoutRef.current!);
+ timeoutRef.current = setTimeout(() => {
+ isInteractingRef.current = false;
+ setIsInteracting(false);
+ }, 2000);
+ };
+
+ el.addEventListener('mousemove', onInteract);
+ el.addEventListener('keydown', onInteract);
+ el.addEventListener('pointerdown', onInteract);
+
+ return () => {
+ clearTimeout(timeoutRef.current!);
+ timeoutRef.current = null;
+ el.removeEventListener('mousemove', onInteract);
+ el.removeEventListener('keydown', onInteract);
+ el.removeEventListener('pointerdown', onInteract);
+ };
+ }, [targetRef]);
+
+ return (
+
+ {children}
+
+ );
}
-
+
type TimelineContextValue = {
- getTime: () => number;
- setTime: (value: number) => void;
- onCommit?: (value: number) => void;
- subscribe: (fn: (value: number) => void) => () => void;
- isPlaying: boolean;
- setIsPlaying: (value: boolean) => void;
+ getTime: () => number;
+ setTime: (value: number) => void;
+ onCommit?: (value: number) => void;
+ subscribe: (fn: (value: number) => void) => () => void;
+ isPlaying: boolean;
+ setIsPlaying: (value: boolean) => void;
};
const TimelineContext = createContext(undefined);
export function useTimeline() {
- const ctx = useContext(TimelineContext);
- if (!ctx) throw new Error("useTimeline must be used within a TimelineProvider");
- return ctx;
+ const ctx = useContext(TimelineContext);
+ if (!ctx) throw new Error('useTimeline must be used within a TimelineProvider');
+ return ctx;
}
-export function TimelineProvider({
- children,
- autoPlay = false,
-}: {
- children: ReactNode;
- autoPlay?: boolean;
-}) {
- const [time, _setTime] = useState(0);
- const [isPlaying, setIsPlaying] = useState(autoPlay);
- const timeRef = useRef(time);
- const rafIdRef = useRef(null);
- const [subscribe, notify] = useSubscribe();
-
- const setTime = useCallback((value: number) => {
- timeRef.current = value;
- notify(value);
- }, [notify]);
-
- const getTime = useCallback(() => timeRef.current, []);
-
- // Example: when time is changed and user commits it
- const onCommit = useCallback((value: number) => {
- timeRef.current = value;
- setTime(value);
- _setTime(value);
- }, [setTime]);
-
- const lastTimestampRef = useRef(null);
-
- useEffect(() => {
- if (isPlaying) {
- const step = (timestamp: number) => {
- if (lastTimestampRef.current == null) {
- lastTimestampRef.current = timestamp;
+export function TimelineProvider({ children, autoPlay = false }: { children: ReactNode; autoPlay?: boolean }) {
+ const [time, _setTime] = useState(0);
+ const [isPlaying, setIsPlaying] = useState(autoPlay);
+ const timeRef = useRef(time);
+ const rafIdRef = useRef(null);
+ const [subscribe, notify] = useSubscribe();
+
+ const setTime = useCallback(
+ (value: number) => {
+ timeRef.current = value;
+ notify(value);
+ },
+ [notify]
+ );
+
+ const getTime = useCallback(() => timeRef.current, []);
+
+ // Example: when time is changed and user commits it
+ const onCommit = useCallback(
+ (value: number) => {
+ timeRef.current = value;
+ setTime(value);
+ _setTime(value);
+ },
+ [setTime]
+ );
+
+ const lastTimestampRef = useRef(null);
+
+ useEffect(() => {
+ if (isPlaying) {
+ const step = (timestamp: number) => {
+ if (lastTimestampRef.current == null) {
+ lastTimestampRef.current = timestamp;
+ }
+
+ const delta = (timestamp - lastTimestampRef.current) / 1000;
+ lastTimestampRef.current = timestamp;
+
+ setTime(getTime() + delta);
+ rafIdRef.current = requestAnimationFrame(step);
+ };
+
+ lastTimestampRef.current = null;
+ rafIdRef.current = requestAnimationFrame(step);
}
- const delta = (timestamp - lastTimestampRef.current) / 1000;
- lastTimestampRef.current = timestamp;
-
- setTime(getTime() + delta);
- rafIdRef.current = requestAnimationFrame(step);
- };
-
- lastTimestampRef.current = null;
- rafIdRef.current = requestAnimationFrame(step);
- }
-
- return () => {
- if (rafIdRef.current) {
- cancelAnimationFrame(rafIdRef.current);
- rafIdRef.current = null;
- }
- };
- }, [isPlaying, getTime, setTime]);
-
- return (
-
- {children}
-
- );
+ return () => {
+ if (rafIdRef.current) {
+ cancelAnimationFrame(rafIdRef.current);
+ rafIdRef.current = null;
+ }
+ };
+ }, [isPlaying, getTime, setTime]);
+
+ return (
+
+ {children}
+
+ );
}
diff --git a/packages/blocks/src/splat-viewer/splat-viewer.tsx b/packages/blocks/src/splat-viewer/splat-viewer.tsx
index 8932618c..2135cd4f 100644
--- a/packages/blocks/src/splat-viewer/splat-viewer.tsx
+++ b/packages/blocks/src/splat-viewer/splat-viewer.tsx
@@ -1,108 +1,106 @@
-"use client"
-
-import { Application, Entity } from "@playcanvas/react"
-import { useApp, useSplat } from "@playcanvas/react/hooks"
-import { GSplat } from "@playcanvas/react/components"
-import { Suspense, useCallback, useEffect, useRef, useState } from "react"
-import { AssetViewerProvider, useAssetViewer, useTimeline } from "./splat-viewer-context.ts"
-import { TooltipProvider } from "@components/ui/tooltip"
-import { cn } from "@lib/utils"
-import { AnimationTrack } from "./utils/animation.ts"
-import { SmartCamera } from "./smart-camera.ts"
-import { HelpDialog } from "./help-dialog.ts"
-import { RESOLUTION_AUTO, FILLMODE_NONE } from "playcanvas"
-import { useSubscribe } from "./hooks/use-subscribe.ts"
-export type CameraMode = 'orbit' | 'fly';
+'use client';
+
+import { Application, Entity } from '@playcanvas/react';
+import { GSplat } from '@playcanvas/react/components';
+import { useApp, useSplat } from '@playcanvas/react/hooks';
+import { RESOLUTION_AUTO, FILLMODE_NONE } from 'playcanvas';
+import { Suspense, useCallback, useEffect, useRef, useState } from 'react';
+
+import { TooltipProvider } from '@components/ui/tooltip';
+import { cn } from '@lib/utils';
+
+import { HelpDialog } from './help-dialog.tsx';
+import { useSubscribe } from './hooks/use-subscribe.ts';
+import { SmartCamera } from './smart-camera.tsx';
+import { AssetViewerProvider, useAssetViewer, useTimeline } from './splat-viewer-context.tsx';
+import type { AnimationTrack } from './utils/animation.ts';
+export type CameraMode = 'orbit' | 'fly';
type CameraControlsProps = {
- /**
- * The type of camera controls to use.
- *
+ /**
+ * The type of camera controls to use.
+ *
* - `orbit`: An orbit camera that follows the splat.
* - `fly`: A fly camera that follows the splat.
- *
+ *
* @defaultValue 'orbit'
*/
- mode?: CameraMode,
+ mode?: CameraMode;
/**
* The default mode of the camera.
- *
+ *
* @defaultValue 'orbit'
*/
- defaultMode?: CameraMode,
-}
-
+ defaultMode?: CameraMode;
+};
type SplatViewerComponentProps = CameraControlsProps & {
/**
* The url of an image to display whilst the asset is loading.
*/
- src: string | Record,
+ src: string | Record;
/**
* The style of the camera to use.
- *
- * - `paris`: A warm saturated style.
+ *
+ * - `paris`: A warm saturated style.
* - `neutral`: A neutral style.
* - `noir`: A black/white style.
- *
+ *
* @defaultValue 'neutral'
*/
- variant?: "paris" | "neutral" | "noir" | 'none' | Record,
+ variant?: 'paris' | 'neutral' | 'noir' | 'none' | Record;
/**
- * The track to use for the animation
+ * The track to use for the animation
*/
- track?: AnimationTrack,
-
+ track?: AnimationTrack;
+
/**
* Whether to automatically play the animation
* @defaultValue false
*/
- autoPlay?: boolean,
+ autoPlay?: boolean;
/**
* A callback function for when the type of camera changes
*/
- onTypeChange?: (type: 'orbit' | 'fly') => void,
+ onTypeChange?: (type: 'orbit' | 'fly') => void;
/**
* A callback function for when the asset progress changes
*/
- onAssetProgress?: (progress: number) => void,
-}
+ onAssetProgress?: (progress: number) => void;
+};
type PosterComponentProps = {
/**
- * The url of the poster image
+ * The url of the poster image
*/
- poster?: string,
-}
-
-export type SplatViewerProps = SplatViewerComponentProps & PosterComponentProps & {
-
- /**
- * The className of the container
- */
- className?: string,
-
- /**
- * The children of the component
- */
- children?: React.ReactNode,
-}
+ poster?: string;
+};
+
+export type SplatViewerProps = SplatViewerComponentProps &
+ PosterComponentProps & {
+ /**
+ * The className of the container
+ */
+ className?: string;
+
+ /**
+ * The children of the component
+ */
+ children?: React.ReactNode;
+ };
const identity = (a: unknown) => a;
-function SplatComponent({
- src,
- variant = "neutral",
- onAssetProgress
-}: SplatViewerComponentProps) {
+function SplatComponent({ src, variant = 'neutral', onAssetProgress }: SplatViewerComponentProps) {
const isObject = typeof src === 'object';
const { asset, error, subscribe } = useSplat(
- isObject ? "vfs://force-use-sogs-parser.json" : src,
- isObject ? { data: src, options: { mapUrl: identity }} : {} );
+ isObject ? 'vfs://force-use-sogs-parser.json' : src,
+ isObject ? { data: src, options: { mapUrl: identity } } : {}
+ );
const { isInteracting } = useAssetViewer();
const { isPlaying } = useTimeline();
@@ -120,6 +118,7 @@ function SplatComponent({
// Hide the cursor when the timeline is playing and the user is not interacting
useEffect(() => {
if (app.graphicsDevice.canvas) {
+ // eslint-disable-next-line react-hooks/immutability -- this effect synchronizes the engine canvas cursor
app.graphicsDevice.canvas.style.cursor = isPlaying && !isInteracting ? 'none' : 'grab';
}
}, [isInteracting, app, isPlaying]);
@@ -147,53 +146,48 @@ function SplatComponent({
canvas.removeEventListener('mouseleave', onMouseUp);
};
}, [app, isPlaying, isInteracting]);
-
+
if (error) throw new Error(error);
if (!asset) return null;
return (
<>
- {
- // type === 'animation' ? :
+ {
+ // type === 'animation' ? :
// type === 'orbit' ? :
- //
+ //
}
>
- )
+ );
}
function PosterComponent({ poster }: PosterComponentProps) {
- return (
-
- )
+ return ;
}
/**
* The SplatViewer is a component that displays a Gaussian Splat.
*/
-export function SplatViewer( {
- src,
- variant = "neutral",
+export function SplatViewer({
+ src,
+ variant = 'neutral',
poster,
mode = 'orbit',
defaultMode = 'orbit',
onTypeChange,
- className,
+ className,
children
-} : SplatViewerProps) {
-
+}: SplatViewerProps) {
const [subscribe, notify] = useSubscribe();
const onAssetProgress = useCallback((progress: number) => notify(progress), [src, notify]);
const isControlled = !mode;
const containerRef = useRef(null!);
- const [uncontrolledMode, setUncontrolledMode] = useState(
- defaultMode
- );
+ const [uncontrolledMode, setUncontrolledMode] = useState(defaultMode);
const setCameraMode = useCallback(
(mode: CameraMode) => {
@@ -206,28 +200,27 @@ export function SplatViewer( {
const currentMode = isControlled ? mode : uncontrolledMode;
return (
-
-
+
- } >
- }>
+
+ graphicsDeviceOptions={{ antialias: false }}
+ >
-
- { children }
-
+ {children}
- )
-}
\ No newline at end of file
+ );
+}
diff --git a/packages/blocks/src/splat-viewer/timeline.tsx b/packages/blocks/src/splat-viewer/timeline.tsx
index 05362213..d4a743d1 100644
--- a/packages/blocks/src/splat-viewer/timeline.tsx
+++ b/packages/blocks/src/splat-viewer/timeline.tsx
@@ -1,53 +1,53 @@
-"use client";
+'use client';
-import { Slider } from "@components/ui/slider";
-import { useTimeline } from "./splat-viewer-context.ts";
-import { Button } from "@components/ui/button";
-import { PauseIcon, PlayIcon } from "lucide-react";
-import { TooltipContent, TooltipTrigger } from "@components/ui/tooltip";
+import { PauseIcon, PlayIcon } from 'lucide-react';
-function PlayButton() {
+import { Button } from '@components/ui/button';
+import { Slider } from '@components/ui/slider';
+import { TooltipContent, TooltipTrigger } from '@components/ui/tooltip';
+
+import { useTimeline } from './splat-viewer-context.tsx';
+function PlayButton() {
const { setIsPlaying, isPlaying } = useTimeline();
return (
<>
-
-
- { isPlaying ? "Pause" : "Play" }
-
+ {isPlaying ? 'Pause' : 'Play'}
>
- )
+ );
}
function Timeline() {
-
const { getTime, setTime, setIsPlaying, onCommit } = useTimeline();
const onValueChange = ([val]: number[]) => {
setIsPlaying(false);
setTime(val);
};
-
+
const onValueCommit = ([val]: number[]) => {
onCommit?.(val);
- }
-
- return (<>
-
-
- >)
-}
-
-export { Timeline };
\ No newline at end of file
+ };
+
+ return (
+ <>
+
+
+ >
+ );
+}
+
+export { Timeline };
diff --git a/packages/blocks/src/splat-viewer/utils/animation.ts b/packages/blocks/src/splat-viewer/utils/animation.ts
index 022170fa..7ce7fe3e 100644
--- a/packages/blocks/src/splat-viewer/utils/animation.ts
+++ b/packages/blocks/src/splat-viewer/utils/animation.ts
@@ -1,33 +1,33 @@
import { Mat4, Vec3 } from 'playcanvas';
import { ExtendedQuat } from './math.ts';
+import type { Pose } from './pose.ts';
import { CubicSpline } from './spline.ts';
-import { Pose } from './pose.ts';
const q = new ExtendedQuat();
export type AnimationTrack = {
/* The name of the track */
- name: string,
+ name: string;
/* The duration of the track */
- duration: number,
+ duration: number;
/* The frame rate of the track */
- frameRate: number,
+ frameRate: number;
/* The target of the track */
- target: string,
+ target: string;
/* The loop mode of the track */
- loopMode: 'none' | 'repeat' | 'pingpong',
+ loopMode: 'none' | 'repeat' | 'pingpong';
/* The interpolation of the track */
- interpolation: 'spline',
+ interpolation: 'spline';
/* The keyframes of the track */
keyframes: {
- times: number[],
+ times: number[];
values: {
- position: number[],
- target: number[]
- }
- }
-}
+ position: number[];
+ target: number[];
+ };
+ };
+};
// track an animation cursor with support for looping and ping-pong modes
class AnimCursor {
@@ -71,21 +71,26 @@ class AnimCursor {
this.cursor = value;
} else {
switch (this.loopMode) {
- case 'none': this.cursor = this.duration; break;
- case 'repeat': this.cursor %= this.duration; break;
- case 'pingpong': this.cursor %= (this.duration * 2); break;
+ case 'none':
+ this.cursor = this.duration;
+ break;
+ case 'repeat':
+ this.cursor %= this.duration;
+ break;
+ case 'pingpong':
+ this.cursor %= this.duration * 2;
+ break;
}
}
}
get value() {
- return this.cursor;// > this.duration ? this.duration - this.cursor : this.cursor;
+ return this.cursor; // > this.duration ? this.duration - this.cursor : this.cursor;
}
}
// Manage the state of a camera animation track
class AnimCamera {
-
time = 0;
spline;
@@ -104,7 +109,7 @@ class AnimCamera {
rotation = new Vec3();
constructor(spline: CubicSpline, duration: number, loopMode: 'none' | 'repeat' | 'pingpong', frameRate: number) {
- this.time = 0
+ this.time = 0;
this.spline = spline;
this.cursor.reset(duration, loopMode);
this.frameRate = frameRate;
@@ -150,7 +155,6 @@ class AnimCamera {
// construct an animation from a settings track
static fromTrack(track: AnimationTrack) {
-
const { keyframes, duration, frameRate, loopMode } = track;
const { times, values } = keyframes;
const { position, target } = values;
@@ -162,30 +166,29 @@ class AnimCamera {
points.push(target[i * 3], target[i * 3 + 1], target[i * 3 + 2]);
}
- const extra = (duration === times[times.length - 1] / frameRate) ? 1 : 0;
+ const extra = duration === times[times.length - 1] / frameRate ? 1 : 0;
const spline = CubicSpline.fromPointsLooping((duration + extra) * frameRate, times, points, -1);
return new AnimCamera(spline, duration, loopMode, frameRate);
}
-
}
type RotationTrackProps = {
/**
* The number of keys to generate
*/
- keys?: number,
+ keys?: number;
/**
* The duration of the track
*/
- duration?: number,
-}
+ duration?: number;
+};
export const createRotationTrack = (initial: Pose, options: RotationTrackProps = { keys: 12, duration: 20 }) => {
const { keys = 12, duration = 20 } = options;
- const times = new Array(keys).fill(0).map((_, i) => i / keys * duration);
+ const times = new Array(keys).fill(0).map((_, i) => (i / keys) * duration);
const position = [];
const target = [];
@@ -201,7 +204,7 @@ export const createRotationTrack = (initial: Pose, options: RotationTrackProps =
);
for (let i = 0; i < keys; ++i) {
- mat.setFromEulerAngles(0, -i / keys * 360, 0);
+ mat.setFromEulerAngles(0, (-i / keys) * 360, 0);
mat.transformPoint(dif, vec);
position.push(initialTarget.x + vec.x);
@@ -229,8 +232,6 @@ export const createRotationTrack = (initial: Pose, options: RotationTrackProps =
}
}
});
-
-
-}
+};
-export { AnimCamera };
\ No newline at end of file
+export { AnimCamera };
diff --git a/packages/blocks/src/splat-viewer/utils/effects.tsx b/packages/blocks/src/splat-viewer/utils/effects.tsx
index 99197a47..b3a9ae27 100644
--- a/packages/blocks/src/splat-viewer/utils/effects.tsx
+++ b/packages/blocks/src/splat-viewer/utils/effects.tsx
@@ -1,8 +1,7 @@
-import { FC } from "react";
-import { Script } from "@playcanvas/react/components";
-
+import { Script } from '@playcanvas/react/components';
// @ts-expect-error There is no type definition for the camera-frame script
-import { CameraFrame } from "playcanvas/scripts/esm/camera-frame.mjs";
+import { CameraFrame } from 'playcanvas/scripts/esm/camera-frame.mjs';
+import type { FC } from 'react';
const deepMerge = (target: Record, source: Record) => {
if (!source || typeof source !== 'object') return target;
@@ -21,79 +20,76 @@ export type PostEffectsSettings = Partial;
export const StaticPostEffects: FC = (props) => {
const settings = deepMerge(defaultPostSettings, props);
- return
-}
+ return ;
+};
const defaultPostSettings = {
- "lighting": {
- "exposure": 1.21,
- "skyBoxIntensity": 1.02
+ lighting: {
+ exposure: 1.21,
+ skyBoxIntensity: 1.02
},
- "rendering": {
- "renderFormat": 18,
- "renderTargetScale": 1,
- "sharpness": 0,
- "samples": 4,
- "toneMapping": 4,
- "fog": "none",
- "fogColor": {
- "r": 0,
- "g": 0,
- "b": 0,
- "a": 1
+ rendering: {
+ renderFormat: 18,
+ renderTargetScale: 1,
+ sharpness: 0,
+ samples: 4,
+ toneMapping: 4,
+ fog: 'none',
+ fogColor: {
+ r: 0,
+ g: 0,
+ b: 0,
+ a: 1
},
- "fogRange": [
- 0,
- 100
- ],
- "fogDensity": 0.01,
- "renderFormatFallback0": 12,
- "renderFormatFallback1": 14,
- "sceneColorMap": false,
- "sceneDepthMap": false,
- "fogStart": 0,
- "fogEnd": 100
+ fogRange: [0, 100],
+ fogDensity: 0.01,
+ renderFormatFallback0: 12,
+ renderFormatFallback1: 14,
+ sceneColorMap: false,
+ sceneDepthMap: false,
+ fogStart: 0,
+ fogEnd: 100
},
- "ssao": {
- "type": "none",
- "intensity": 0.5,
- "radius": 30,
- "samples": 12,
- "power": 6,
- "minAngle": 10,
- "scale": 1,
- "blurEnabled": true
+ ssao: {
+ type: 'none',
+ intensity: 0.5,
+ radius: 30,
+ samples: 12,
+ power: 6,
+ minAngle: 10,
+ scale: 1,
+ blurEnabled: true
},
- "bloom": {
- "enabled": true,
- "intensity": 0.1,
- "lastMipLevel": 1
+ bloom: {
+ enabled: true,
+ intensity: 0.1,
+ lastMipLevel: 1
},
- "grading": {
- "enabled": true,
- "brightness": 0.83,
- "contrast": 1.06,
- "saturation": 1.2,
- "tint": {
- "r": 1,
- "g": 0.9333333333333333,
- "b": 0.8666666666666667,
- "a": 1
+ grading: {
+ enabled: true,
+ brightness: 0.83,
+ contrast: 1.06,
+ saturation: 1.2,
+ tint: {
+ r: 1,
+ g: 0.9333333333333333,
+ b: 0.8666666666666667,
+ a: 1
}
},
- "vignette": {
- "enabled": true,
- "intensity": 1,
- "inner": 0.25,
- "outer": 1.52,
- "curvature": 0.78
+ vignette: {
+ enabled: true,
+ intensity: 1,
+ inner: 0.25,
+ outer: 1.52,
+ curvature: 0.78
},
- "taa": {
- "enabled": false,
- "jitter": 0.4
+ taa: {
+ enabled: false,
+ jitter: 0.4
},
- "fringing": {
- "enabled": true,
- "intensity": 10
+ fringing: {
+ enabled: true,
+ intensity: 10
}
-}
\ No newline at end of file
+};
diff --git a/packages/blocks/src/splat-viewer/utils/math.ts b/packages/blocks/src/splat-viewer/utils/math.ts
index 2c9a7a1d..c35f994c 100644
--- a/packages/blocks/src/splat-viewer/utils/math.ts
+++ b/packages/blocks/src/splat-viewer/utils/math.ts
@@ -12,7 +12,6 @@ const y = new Vec3();
const z = new Vec3();
class ExtendedQuat extends Quat {
-
// set a quaternion given an orthonormal basis
fromBasis(x: Vec3, y: Vec3, z: Vec3) {
const m00 = x.x;
diff --git a/packages/blocks/src/splat-viewer/utils/mock-anim-track.ts b/packages/blocks/src/splat-viewer/utils/mock-anim-track.ts
index 496c021f..7249e10f 100644
--- a/packages/blocks/src/splat-viewer/utils/mock-anim-track.ts
+++ b/packages/blocks/src/splat-viewer/utils/mock-anim-track.ts
@@ -1,134 +1,41 @@
-import { AnimationTrack } from "./animation.ts";
+import type { AnimationTrack } from './animation.ts';
export const mockAnimTrack: AnimationTrack[] = [
{
- "name": "cameraAnim",
- "duration": 32,
- "frameRate": 30,
- "target": "camera",
- "loopMode": "repeat",
- "interpolation": "spline",
- "keyframes": {
- "times": [
- 0,
- 100,
- 120,
- 220,
- 240,
- 340,
- 360,
- 460,
- 480,
- 580,
- 600,
- 700,
- 720,
- 820,
- 840,
- 940
- ],
- "values": {
- "position": [
- 0.9025543332099915,
- 0.7663149833679199,
- -6.066800117492676,
- 0.9443151354789734,
- 0.7445814609527588,
- -5.217763423919678,
- 2.105348825454712,
- 1.2011923789978027,
- -4.945537090301514,
- 2.910306930541992,
- 1.2433737516403198,
- -4.945185661315918,
- 5.770991802215576,
- 1.1668169498443604,
- -3.0014231204986572,
- 5.795348167419434,
- 1.1529899835586548,
- -1.9889216423034668,
- 8.328794479370117,
- 1.3998260498046875,
- -1.9957057237625122,
- 10.107688903808594,
- 1.464301347732544,
- -1.9489798545837402,
- 9.592545509338379,
- 1.337570309638977,
- -3.3290841579437256,
- 8.06981086730957,
- 1.2422486543655396,
- -3.5843799114227295,
- 7.9591264724731445,
- 1.204317569732666,
- -3.743701934814453,
- 7.920379161834717,
- 1.2193323373794556,
- -4.855903148651123,
- 5.151504039764404,
- 1.1198210716247559,
- -5.18406867980957,
- 5.088464260101318,
- 1.0306692123413086,
- -5.907327651977539,
- 2.6927692890167236,
- 1.0159400701522827,
- -7.158919811248779,
- 1.9255868196487427,
- 1.0222928524017334,
- -7.189198970794678
+ name: 'cameraAnim',
+ duration: 32,
+ frameRate: 30,
+ target: 'camera',
+ loopMode: 'repeat',
+ interpolation: 'spline',
+ keyframes: {
+ times: [0, 100, 120, 220, 240, 340, 360, 460, 480, 580, 600, 700, 720, 820, 840, 940],
+ values: {
+ position: [
+ 0.9025543332099915, 0.7663149833679199, -6.066800117492676, 0.9443151354789734, 0.7445814609527588,
+ -5.217763423919678, 2.105348825454712, 1.2011923789978027, -4.945537090301514, 2.910306930541992,
+ 1.2433737516403198, -4.945185661315918, 5.770991802215576, 1.1668169498443604, -3.0014231204986572,
+ 5.795348167419434, 1.1529899835586548, -1.9889216423034668, 8.328794479370117, 1.3998260498046875,
+ -1.9957057237625122, 10.107688903808594, 1.464301347732544, -1.9489798545837402, 9.592545509338379,
+ 1.337570309638977, -3.3290841579437256, 8.06981086730957, 1.2422486543655396, -3.5843799114227295,
+ 7.9591264724731445, 1.204317569732666, -3.743701934814453, 7.920379161834717, 1.2193323373794556,
+ -4.855903148651123, 5.151504039764404, 1.1198210716247559, -5.18406867980957, 5.088464260101318,
+ 1.0306692123413086, -5.907327651977539, 2.6927692890167236, 1.0159400701522827, -7.158919811248779,
+ 1.9255868196487427, 1.0222928524017334, -7.189198970794678
],
- "target": [
- 2.6959114225577614,
- 0.5935490731159917,
- -6.010441410732681,
- 2.7376722120791723,
- 0.5718155372449387,
- -5.161404758398473,
- 2.060194276924096,
- 0.7252839671533393,
- -2.071161809788783,
- 3.010252833611601,
- 0.7478458757608917,
- -2.1661836041457105,
- 8.260718510291486,
- 0.8694271253551379,
- -3.0550210108537934,
- 8.026174736778582,
- 0.9050725764789409,
- -2.009617402922006,
- 13.384634770488692,
- 1.10482014753014,
- -2.0686596875349625,
- 13.411044880887754,
- 1.26899126909825,
- -1.9930150816243941,
- 9.655177175988594,
- 0.7447358616318187,
- -8.165233629691294,
- 8.095726351784839,
- 0.6876282036071057,
- -7.981414844576479,
- 5.474331160460829,
- 0.8637022778425204,
- -3.7781510988474847,
- 5.15853325176016,
- 0.8072772916181638,
- -4.850212616556235,
- 2.2295135561300095,
- 0.3421215890893258,
- -5.192369046201025,
- 2.476742131324072,
- 0.3737140032391304,
- -5.91719812578656,
- 2.696477149764378,
- 0.1322720662613842,
- -5.438379817727611,
- 1.9247772229873576,
- 0.07824187288925666,
- -5.313601506279998
- ]
+ target: [
+ 2.6959114225577614, 0.5935490731159917, -6.010441410732681, 2.7376722120791723, 0.5718155372449387,
+ -5.161404758398473, 2.060194276924096, 0.7252839671533393, -2.071161809788783, 3.010252833611601,
+ 0.7478458757608917, -2.1661836041457105, 8.260718510291486, 0.8694271253551379, -3.0550210108537934,
+ 8.026174736778582, 0.9050725764789409, -2.009617402922006, 13.384634770488692, 1.10482014753014,
+ -2.0686596875349625, 13.411044880887754, 1.26899126909825, -1.9930150816243941, 9.655177175988594,
+ 0.7447358616318187, -8.165233629691294, 8.095726351784839, 0.6876282036071057, -7.981414844576479,
+ 5.474331160460829, 0.8637022778425204, -3.7781510988474847, 5.15853325176016, 0.8072772916181638,
+ -4.850212616556235, 2.2295135561300095, 0.3421215890893258, -5.192369046201025, 2.476742131324072,
+ 0.3737140032391304, -5.91719812578656, 2.696477149764378, 0.1322720662613842, -5.438379817727611,
+ 1.9247772229873576, 0.07824187288925666, -5.313601506279998
+ ]
}
}
}
-]
\ No newline at end of file
+];
diff --git a/packages/blocks/src/splat-viewer/utils/pose.ts b/packages/blocks/src/splat-viewer/utils/pose.ts
index b5859710..aff0f9fb 100644
--- a/packages/blocks/src/splat-viewer/utils/pose.ts
+++ b/packages/blocks/src/splat-viewer/utils/pose.ts
@@ -1,4 +1,5 @@
-import { Application, BoundingBox, GSplatComponent, Vec3 } from 'playcanvas';
+import type { Application, GSplatComponent } from 'playcanvas';
+import { BoundingBox, Vec3 } from 'playcanvas';
import { lerp, ExtendedQuat } from './math.ts';
@@ -6,10 +7,9 @@ const v = new Vec3();
// stores a camera pose
class Pose {
-
position: Vec3 = new Vec3();
rotation: ExtendedQuat = new ExtendedQuat();
- distance: number = 1;
+ distance = 1;
constructor(other = null) {
if (other) {
@@ -45,21 +45,20 @@ class Pose {
}
export type PoseType = {
- position: [number, number, number],
- target: [number, number, number]
-}
-
-const computeStartingPose = (app: Application, fov: number) : PoseType => {
+ position: [number, number, number];
+ target: [number, number, number];
+};
+const computeStartingPose = (app: Application, fov: number): PoseType => {
const gsplat = app.root.findComponent('gsplat') as unknown as GSplatComponent;
if (!gsplat) {
- throw new Error("GSplat not found");
+ throw new Error('GSplat not found');
}
const bbox = gsplat?.instance?.meshInstance?.aabb ?? new BoundingBox();
const sceneSize = bbox.halfExtents.length() * 1.5;
- const distance = sceneSize / Math.sin(fov / 180 * Math.PI * 0.5);
+ const distance = sceneSize / Math.sin((fov / 180) * Math.PI * 0.5);
const position = new Vec3(2, 1, 2).normalize().mulScalar(distance).add(bbox.center).toArray();
const target = bbox.center.toArray();
@@ -68,6 +67,6 @@ const computeStartingPose = (app: Application, fov: number) : PoseType => {
position,
target
} as PoseType;
-}
+};
export { Pose, computeStartingPose };
diff --git a/packages/blocks/src/splat-viewer/utils/spline.ts b/packages/blocks/src/splat-viewer/utils/spline.ts
index 98b500f8..72194bba 100644
--- a/packages/blocks/src/splat-viewer/utils/spline.ts
+++ b/packages/blocks/src/splat-viewer/utils/spline.ts
@@ -56,11 +56,7 @@ class CubicSpline {
const p1 = knots[idx + dim * 3 + 1];
idx += 3;
- result[i] =
- p0 * ((1 + twot) * omt2) +
- m0 * (t * omt2) +
- p1 * (t2 * (3 - twot)) +
- m1 * (t2 * (t - 1));
+ result[i] = p0 * ((1 + twot) * omt2) + m0 * (t * omt2) + p1 * (t2 * (3 - twot)) + m1 * (t2 * (t - 1));
}
}
@@ -86,14 +82,16 @@ class CubicSpline {
tangent = (p - points[idx - dim]) / (t - times[i - 1]);
} else {
// finite difference tangents
- tangent = 0.5 * ((points[idx + dim] - p) / (times[i + 1] - t) + (p - points[idx - dim]) / (t - times[i - 1]));
+ tangent =
+ 0.5 *
+ ((points[idx + dim] - p) / (times[i + 1] - t) + (p - points[idx - dim]) / (t - times[i - 1]));
// cardinal spline tangents
// tangent = (points[idx + dim] - points[idx - dim]) / (times[i + 1] - times[i - 1]);
}
// apply tension
- tangent *= (1.0 - tension);
+ tangent *= 1.0 - tension;
knots[idx * 3] = tangent;
knots[idx * 3 + 1] = p;
diff --git a/packages/blocks/src/splat-viewer/utils/style.ts b/packages/blocks/src/splat-viewer/utils/style.ts
index 54efd37f..33798383 100644
--- a/packages/blocks/src/splat-viewer/utils/style.ts
+++ b/packages/blocks/src/splat-viewer/utils/style.ts
@@ -1,4 +1,4 @@
-import { PostEffectsSettings } from "./effects.ts"
+import type { PostEffectsSettings } from './effects.ts';
export const paris: PostEffectsSettings = {
lighting: {
@@ -11,17 +11,14 @@ export const paris: PostEffectsSettings = {
sharpness: 0,
samples: 4,
toneMapping: 4,
- fog: "none",
+ fog: 'none',
fogColor: {
r: 0,
g: 0,
b: 0,
a: 1
},
- fogRange: [
- 0,
- 100
- ],
+ fogRange: [0, 100],
fogDensity: 0.01,
renderFormatFallback0: 12,
renderFormatFallback1: 14,
@@ -31,7 +28,7 @@ export const paris: PostEffectsSettings = {
fogEnd: 100
},
ssao: {
- type: "none",
+ type: 'none',
intensity: 0.5,
radius: 30,
samples: 12,
@@ -72,7 +69,7 @@ export const paris: PostEffectsSettings = {
enabled: true,
intensity: 10
}
-}
+};
export const neutral: PostEffectsSettings = {
lighting: {
@@ -85,17 +82,14 @@ export const neutral: PostEffectsSettings = {
sharpness: 0,
samples: 8,
toneMapping: 4,
- fog: "none",
+ fog: 'none',
fogColor: {
r: 0,
g: 0,
b: 0,
a: 1
},
- fogRange: [
- 0,
- 100
- ],
+ fogRange: [0, 100],
fogDensity: 0.01,
renderFormatFallback0: 12,
renderFormatFallback1: 14,
@@ -105,7 +99,7 @@ export const neutral: PostEffectsSettings = {
fogEnd: 100
},
ssao: {
- type: "none",
+ type: 'none',
intensity: 0.5,
radius: 30,
samples: 12,
@@ -146,7 +140,7 @@ export const neutral: PostEffectsSettings = {
enabled: false,
intensity: 10
}
-}
+};
export const noir: PostEffectsSettings = {
lighting: {
@@ -159,17 +153,14 @@ export const noir: PostEffectsSettings = {
sharpness: 0,
samples: 4,
toneMapping: 4,
- fog: "none",
+ fog: 'none',
fogColor: {
r: 0,
g: 0,
b: 0,
a: 1
},
- fogRange: [
- 0,
- 100
- ],
+ fogRange: [0, 100],
fogDensity: 0.01,
renderFormatFallback0: 12,
renderFormatFallback1: 14,
@@ -179,7 +170,7 @@ export const noir: PostEffectsSettings = {
fogEnd: 100
},
ssao: {
- type: "none",
+ type: 'none',
intensity: 0.5,
radius: 30,
samples: 12,
@@ -220,4 +211,4 @@ export const noir: PostEffectsSettings = {
enabled: true,
intensity: 10
}
-}
\ No newline at end of file
+};
diff --git a/packages/blocks/tsconfig.json b/packages/blocks/tsconfig.json
index 705bedc0..648b14b3 100644
--- a/packages/blocks/tsconfig.json
+++ b/packages/blocks/tsconfig.json
@@ -21,7 +21,7 @@
"@components/*": ["./src/components/*"],
"@splat-viewer/*": ["./src/splat-viewer/*"],
"@lib/*": ["./src/lib/*"]
- },
+ }
},
"include": ["src/**/*"],
"exclude": ["src/**/*.test.tsx", "src/**/*.test.ts", "src/**/*.test.jsx", "src/**/*.test.js"]
diff --git a/packages/lib/CHANGELOG.md b/packages/lib/CHANGELOG.md
index f4e8250c..7ebf9d68 100644
--- a/packages/lib/CHANGELOG.md
+++ b/packages/lib/CHANGELOG.md
@@ -7,10 +7,10 @@
- 4f25e4e: Fix stale homepage URLs in package metadata: `@playcanvas/react` now points at the canonical docs path and `@playcanvas/blocks` at its GitHub README instead of the retired Vercel site.
- f88e26e: Fix the `Screen` and `Element` components, which previously failed to render.
- - `` threw `Cannot set property aabb` on mount: the generated prop schema included read-only engine getters (such as `aabb`) and tried to assign to them. It also applied `null` defaults for props the user never set, which broke settable-but-initially-null props like `color` (`this._color.copy(null)`). Read-only accessors are now excluded from the schema, and the schema no longer assigns `null`/`undefined` defaults.
- - `` rendered nothing because `referenceResolution` was applied as a raw array; the engine setter reads `value.x`/`value.y`, so it now receives a `Vec2`.
+ - `` threw `Cannot set property aabb` on mount: the generated prop schema included read-only engine getters (such as `aabb`) and tried to assign to them. It also applied `null` defaults for props the user never set, which broke settable-but-initially-null props like `color` (`this._color.copy(null)`). Read-only accessors are now excluded from the schema, and the schema no longer assigns `null`/`undefined` defaults.
+ - `` rendered nothing because `referenceResolution` was applied as a raw array; the engine setter reads `value.x`/`value.y`, so it now receives a `Vec2`.
- Also corrects the `Element` component's JSDoc (previously a copy of `Screen`'s, with an invalid example).
+ Also corrects the `Element` component's JSDoc (previously a copy of `Screen`'s, with an invalid example).
## 0.11.4
diff --git a/packages/lib/README.md b/packages/lib/README.md
index bd2bfa0d..b1a81b77 100644
--- a/packages/lib/README.md
+++ b/packages/lib/README.md
@@ -5,6 +5,7 @@
[](https://pkg.pr.new/~/playcanvas/react)
## @playcanvas/react
+
_⚡ A full-featured library for interactive 3D in React._
@playcanvas/react is a thin wrapper around PlayCanvas - a batteries included library for building interactive 3D content in React. Designed to get you up and running fast.
@@ -58,13 +59,14 @@ npx create playcanvas@latest -t react-t
Or install in an existing project with your favorite package manager:
**npm**
+
```bash
npm install @playcanvas/react
```
### Show me the code
-Here's how you render a sphere.
+Here's how you render a sphere.
```jsx
import { Application, Entity } from '@playcanvas/react';
@@ -72,16 +74,16 @@ import { Camera, Render } from '@playcanvas/react/components';
import { OrbitControls } from '@playcanvas/react/scripts';
export function AssetViewer() {
- return (
-
-
-
-
-
-
-
- );
-};
+ return (
+
+
+
+
+
+
+
+ );
+}
```
Et voilà! ✨
@@ -135,9 +137,8 @@ If you want to build the repo from scratch, check out the following. The monorep
- [@playcanvas/blocks](/packages/blocks) - High level React components for common 3D use cases
- [@playcanvas/docs](/packages/docs) - The Documentation site.
-If you want to run this entire project locally, including docs just `npm install` and `npm run dev` from the root of the monorepo. This will install and build all the local dependencies and run a local webserver of the docs.
+If you want to run this entire project locally, including docs just `npm install` and `npm run dev` from the root of the monorepo. This will install and build all the local dependencies and run a local webserver of the docs.
You can make changes to either the react or blocks lib and build them locally by doing `npm run build:lib` or `npm run build:blocks` respectively.
All contributions are welcome :heart:
-
diff --git a/packages/lib/eslint.config.mjs b/packages/lib/eslint.config.mjs
index b08f27d4..62330015 100644
--- a/packages/lib/eslint.config.mjs
+++ b/packages/lib/eslint.config.mjs
@@ -1,32 +1,34 @@
-import baseConfig from '../../eslint.config.mjs';
import importPlugin from 'eslint-plugin-import';
+import baseConfig from '../../eslint.config.mjs';
+
/** @type {import('eslint').Linter.Config[]} */
export default [
- ...baseConfig,
- {
- plugins: {
- 'import': importPlugin,
- },
- settings: {
- 'import/resolver': {
- node: {
- extensions: ['.js', '.ts', '.json']
+ ...baseConfig,
+ {
+ plugins: {
+ import: importPlugin
+ },
+ settings: {
+ 'import/resolver': {
+ node: {
+ extensions: ['.js', '.ts', '.json']
+ }
+ }
+ },
+ rules: {
+ 'import/extensions': [
+ 'error',
+ 'ignorePackages',
+ {
+ js: 'always',
+ ts: 'always',
+ tsx: 'always'
+ }
+ ]
}
- }
},
- rules: {
- 'import/extensions': ['error', 'ignorePackages', {
- js: 'always',
- ts: 'always',
- tsx: 'always',
- }]
+ {
+ ignores: ['dist/', 'node_modules/']
}
- },
- {
- ignores: [
- "dist/",
- "node_modules/",
- ],
- },
-];
\ No newline at end of file
+];
diff --git a/packages/lib/package.json b/packages/lib/package.json
index 4110eb53..732e6981 100644
--- a/packages/lib/package.json
+++ b/packages/lib/package.json
@@ -1,19 +1,7 @@
{
"name": "@playcanvas/react",
- "description": "A React renderer for PlayCanvas – build interactive 3D applications using React's declarative paradigm.",
- "homepage": "https://developer.playcanvas.com/user-manual/react/",
"version": "0.11.5",
- "license": "MIT",
- "type": "module",
- "sideEffects": false,
- "main": "dist/index.js",
- "types": "dist/index.d.ts",
- "scripts": {
- "build": "tsc",
- "test": "vitest run",
- "test:watch": "vitest",
- "test:coverage": "vitest run --coverage"
- },
+ "description": "A React renderer for PlayCanvas – build interactive 3D applications using React's declarative paradigm.",
"keywords": [
"2d",
"3d",
@@ -28,9 +16,7 @@
"webgpu",
"webxr"
],
- "engines": {
- "node": ">=18.0.0"
- },
+ "homepage": "https://developer.playcanvas.com/user-manual/react/",
"bugs": {
"url": "https://github.com/playcanvas/react/issues"
},
@@ -39,9 +25,9 @@
"url": "https://github.com/playcanvas/react",
"directory": "packages/lib"
},
- "files": [
- "dist"
- ],
+ "license": "MIT",
+ "sideEffects": false,
+ "type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
@@ -51,6 +37,10 @@
"types": "./dist/components/index.d.ts",
"import": "./dist/components/index.js"
},
+ "./hooks": {
+ "types": "./dist/hooks/index.d.ts",
+ "import": "./dist/hooks/index.js"
+ },
"./scripts": {
"types": "./dist/scripts/index.d.ts",
"import": "./dist/scripts/index.js"
@@ -58,12 +48,10 @@
"./utils": {
"types": "./dist/utils/index.d.ts",
"import": "./dist/utils/index.js"
- },
- "./hooks": {
- "types": "./dist/hooks/index.d.ts",
- "import": "./dist/hooks/index.js"
}
},
+ "main": "dist/index.js",
+ "types": "dist/index.d.ts",
"typesVersions": {
"*": {
"components": [
@@ -80,19 +68,17 @@
]
}
},
- "peerDependencies": {
- "playcanvas": "^2.11.8",
- "react": "^18.3.1 || ^19.1.0",
- "react-dom": "^18.3.1 || ^19.1.0",
- "sync-ammo": "^0.1.2"
- },
- "peerDependenciesMeta": {
- "sync-ammo": {
- "optional": true
- }
+ "files": [
+ "dist"
+ ],
+ "scripts": {
+ "build": "tsc",
+ "test": "vitest run",
+ "test:coverage": "vitest run --coverage",
+ "test:watch": "vitest"
},
- "publishConfig": {
- "access": "public"
+ "dependencies": {
+ "dedent": "^1.6.0"
},
"devDependencies": {
"@testing-library/jest-dom": "6.9.1",
@@ -106,7 +92,21 @@
"jsdom": "27.4.0",
"vitest": "4.1.8"
},
- "dependencies": {
- "dedent": "^1.6.0"
+ "peerDependencies": {
+ "playcanvas": "^2.11.8",
+ "react": "^18.3.1 || ^19.1.0",
+ "react-dom": "^18.3.1 || ^19.1.0",
+ "sync-ammo": "^0.1.2"
+ },
+ "peerDependenciesMeta": {
+ "sync-ammo": {
+ "optional": true
+ }
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "publishConfig": {
+ "access": "public"
}
}
diff --git a/packages/lib/src/Application.test.tsx b/packages/lib/src/Application.test.tsx
index b39078cd..2e354657 100644
--- a/packages/lib/src/Application.test.tsx
+++ b/packages/lib/src/Application.test.tsx
@@ -1,109 +1,107 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import '@testing-library/jest-dom';
+// eslint-disable-next-line import-x/order -- preserve the existing side-effect import order
import React from 'react';
+
import { Application, ApplicationWithoutCanvas } from './Application.tsx';
describe('Application', () => {
- beforeEach(() => {
- vi.clearAllMocks();
- });
-
- it('The Application component renders with default props', () => {
- render();
-
- const canvas = screen.getByLabelText('Interactive 3D Scene');
- expect(canvas).toBeInTheDocument();
- });
-
- it('The Application component applies className prop correctly', () => {
- render();
-
- const canvas = screen.getByLabelText('Interactive 3D Scene');
- expect(canvas).toHaveClass('test-class');
- });
-
- it('The Application component applies style prop correctly', () => {
- render();
-
- const canvas = screen.getByLabelText('Interactive 3D Scene');
- expect(canvas).toHaveStyle({ width: '200px', height: '200px' });
- });
-
-
- it('warns when invalid deviceTypes prop is provided', async () => {
- // Spy on console.warn
- const warnSpy = vi
- .spyOn(console, 'warn')
- .mockImplementation(() => {});
-
- try {
- // @ts-expect-error - we want to test the warning
- render();
-
- // Wait for the warning appears
- await vi.waitFor(() =>
- expect(warnSpy).toHaveBeenCalledWith(
- "%c[PlayCanvas React]:",
- expect.any(String), // the CSS string
- expect.stringContaining(
- 'deviceTypes must be an array containing one or more of:'
- )
- )
- );
- } finally {
- warnSpy.mockRestore();
- }
- });
-
- it.skip('The Application component applies canvasRef prop correctly', () => {
- const canvasRef = { current: document.createElement('canvas') };
- canvasRef.current.setAttribute('aria-label', 'test-canvas');
- render();
-
- const canvas = screen.getByLabelText('test-canvas');
- expect(canvas).toBe(canvasRef.current);
- });
-
- it.skip('validates prop types correctly', async () => {
- const messages: string[] = [];
- const originalWarn = console.warn;
- const originalLog = console.log;
-
- // Mute console output
- console.warn = vi.fn((...args: (string | number | boolean)[]) => {
- const message = args.join(' ');
- messages.push(message);
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('The Application component renders with default props', () => {
+ render();
+
+ const canvas = screen.getByLabelText('Interactive 3D Scene');
+ expect(canvas).toBeInTheDocument();
});
- console.log = vi.fn();
-
- // Test invalid graphicsDeviceOptions
- render(
-
- );
-
- // Wait for the async warning
- await vi.waitFor(() => {
- const hasWarning = messages.some(msg => msg.includes('Invalid value for prop "graphicsDeviceOptions"'));
- expect(hasWarning).toBe(true);
+
+ it('The Application component applies className prop correctly', () => {
+ render();
+
+ const canvas = screen.getByLabelText('Interactive 3D Scene');
+ expect(canvas).toHaveClass('test-class');
});
- // Restore console
- console.warn = originalWarn;
- console.log = originalLog;
- });
+ it('The Application component applies style prop correctly', () => {
+ render();
- it.skip('cleans up resources on unmount', () => {
- const { unmount } = render();
+ const canvas = screen.getByLabelText('Interactive 3D Scene');
+ expect(canvas).toHaveStyle({ width: '200px', height: '200px' });
+ });
- unmount();
+ it('warns when invalid deviceTypes prop is provided', async () => {
+ // Spy on console.warn
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
+
+ try {
+ // @ts-expect-error - we want to test the warning
+ render();
+
+ // Wait for the warning appears
+ await vi.waitFor(() =>
+ expect(warnSpy).toHaveBeenCalledWith(
+ '%c[PlayCanvas React]:',
+ expect.any(String), // the CSS string
+ expect.stringContaining('deviceTypes must be an array containing one or more of:')
+ )
+ );
+ } finally {
+ warnSpy.mockRestore();
+ }
+ });
- // Verify that cleanup functions were called
- expect(vi.mocked(window.ResizeObserver)).toHaveBeenCalled();
- });
+ it.skip('The Application component applies canvasRef prop correctly', () => {
+ const canvasRef = { current: document.createElement('canvas') };
+ canvasRef.current.setAttribute('aria-label', 'test-canvas');
+ render();
-});
\ No newline at end of file
+ const canvas = screen.getByLabelText('test-canvas');
+ expect(canvas).toBe(canvasRef.current);
+ });
+
+ it.skip('validates prop types correctly', async () => {
+ const messages: string[] = [];
+ const originalWarn = console.warn;
+ const originalLog = console.log;
+
+ // Mute console output
+ console.warn = vi.fn((...args: (string | number | boolean)[]) => {
+ const message = args.join(' ');
+ messages.push(message);
+ });
+ console.log = vi.fn();
+
+ // Test invalid graphicsDeviceOptions
+ render(
+
+ );
+
+ // Wait for the async warning
+ await vi.waitFor(() => {
+ const hasWarning = messages.some((msg) => msg.includes('Invalid value for prop "graphicsDeviceOptions"'));
+ expect(hasWarning).toBe(true);
+ });
+
+ // Restore console
+ console.warn = originalWarn;
+ console.log = originalLog;
+ });
+
+ it.skip('cleans up resources on unmount', () => {
+ const { unmount } = render();
+
+ unmount();
+
+ // Verify that cleanup functions were called
+ expect(vi.mocked(window.ResizeObserver)).toHaveBeenCalled();
+ });
+});
diff --git a/packages/lib/src/Application.tsx b/packages/lib/src/Application.tsx
index a38fe57a..a499c732 100644
--- a/packages/lib/src/Application.tsx
+++ b/packages/lib/src/Application.tsx
@@ -1,82 +1,86 @@
-import React, { FC, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
+import type { Entity as PcEntity } from 'playcanvas';
import {
- FILLMODE_NONE,
- FILLMODE_FILL_WINDOW,
- FILLMODE_KEEP_ASPECT,
- RESOLUTION_AUTO,
- Application as PlayCanvasApplication,
- Mouse,
- TouchDevice,
- Entity as PcEntity,
- RESOLUTION_FIXED,
- DEVICETYPE_WEBGL2,
- DEVICETYPE_WEBGPU,
- DEVICETYPE_NULL,
+ FILLMODE_NONE,
+ FILLMODE_FILL_WINDOW,
+ FILLMODE_KEEP_ASPECT,
+ RESOLUTION_AUTO,
+ Application as PlayCanvasApplication,
+ Mouse,
+ TouchDevice,
+ RESOLUTION_FIXED,
+ DEVICETYPE_WEBGL2,
+ DEVICETYPE_WEBGPU,
+ DEVICETYPE_NULL
} from 'playcanvas';
-import { AppContext, ParentContext } from './hooks/index.ts';
-import { PointerEventsContext } from './contexts/pointer-events-context.tsx';
-import { usePicker } from './utils/picker.tsx';
+import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
+import type { FC } from 'react';
+
import { PhysicsProvider } from './contexts/physics-context.tsx';
-import { validatePropsWithDefaults, createComponentDefinition, Schema, getNullApplication, applyProps } from './utils/validation.ts';
-import { PublicProps } from './utils/types-utils.ts';
-import { GraphicsDeviceOptions, defaultGraphicsDeviceOptions } from './types/graphics-device-options.ts';
-import { DeviceType, internalCreateGraphicsDevice } from './utils/create-graphics-device.ts';
+import { PointerEventsContext } from './contexts/pointer-events-context.tsx';
+import { AppContext, ParentContext } from './hooks/index.ts';
+import type { GraphicsDeviceOptions } from './types/graphics-device-options.ts';
+import { defaultGraphicsDeviceOptions } from './types/graphics-device-options.ts';
+import type { DeviceType } from './utils/create-graphics-device.ts';
+import { internalCreateGraphicsDevice } from './utils/create-graphics-device.ts';
import { env } from './utils/env.ts';
+import { usePicker } from './utils/picker.tsx';
+import type { PublicProps } from './utils/types-utils.ts';
+import {
+ validatePropsWithDefaults,
+ createComponentDefinition,
+ getNullApplication,
+ applyProps
+} from './utils/validation.ts';
+import type { Schema } from './utils/validation.ts';
/**
* The **Application** component is the root node of the PlayCanvas React API. It creates a canvas element
* and initializes a PlayCanvas application instance.
- *
+ *
* @param {ApplicationProps} props - The props to pass to the application component.
* @returns {React.ReactNode} - The application component.
- *
+ *
* @example
*
*
*
*/
-export const Application: React.FC = ({
- children,
- className = 'pc-app',
- style = { width: '100%', height: '100%' },
- ...props
+export const Application: React.FC = ({
+ children,
+ className = 'pc-app',
+ style = { width: '100%', height: '100%' },
+ ...props
}) => {
- const canvasRef = useRef(null);
-
- return (
- <>
-
-
-
- {children}
-
- >
- );
+ const canvasRef = useRef(null);
+
+ return (
+ <>
+
+
+
+ {children}
+
+ >
+ );
};
const Canvas = React.memo(
- React.forwardRef(
- (props : CanvasProps, ref) => {
- const { className, style } = props;
- return ;
- }
- )
+ React.forwardRef((props: CanvasProps, ref) => {
+ const { className, style } = props;
+ return ;
+ })
);
/**
- * An alternative Application component that does not create a canvas element.
+ * An alternative Application component that does not create a canvas element.
* This allows you to create a canvas independently from PlayCanvas and pass it in as a ref.
- *
+ *
* @param {ApplicationWithoutCanvasProps} props - The props to pass to the application component.
* @returns {React.ReactNode} The application component.
- *
+ *
* @example
* const canvasRef = useRef(null);
- *
+ *
* return (
* <>
*
@@ -87,245 +91,257 @@ const Canvas = React.memo(
* );
*/
export const ApplicationWithoutCanvas: FC = (props) => {
-
- const { children, ...propsToValidate } = props;
-
- const validatedProps = validatePropsWithDefaults(
- propsToValidate,
- componentDefinition
- );
-
- const {
- canvasRef,
- fillMode = FILLMODE_NONE,
- resolutionMode = RESOLUTION_AUTO,
- usePhysics = false,
- graphicsDeviceOptions,
- deviceTypes = [DEVICETYPE_WEBGL2],
- ...otherProps
- } = validatedProps;
-
- // Create a deviceTypes key to avoid re-rendering the application when the deviceTypes prop changes.
- const deviceTypeKey = deviceTypes.join('-');
-
- /**
- * Also create a key for the graphicsDeviceOptions object to avoid
- * re-rendering the application when the graphicsDeviceOptions prop changes.
- * We need to sort the keys to create a stable key.
- */
- const graphicsOptsKey = useMemo(() => {
- if (!graphicsDeviceOptions) return 'none';
- return Object.entries(graphicsDeviceOptions)
- .sort(([a], [b]) => a.localeCompare(b)) // order-insensitive
- .map(([k, v]) => `${k}:${String(v)}`)
- .join('|');
- }, [graphicsDeviceOptions]);
-
- /**
- * Memoize the graphicsDeviceOptions object to avoid re-rendering the application when the graphicsDeviceOptions prop changes.
- */
- const graphicsOpts = useMemo(
- () => ({ ...defaultGraphicsDeviceOptions, ...graphicsDeviceOptions }),
- [graphicsDeviceOptions] // ← only changes when *values* change
- );
-
- const [app, setApp] = useState(null);
- const appRef = useRef(null);
-
- const pointerEvents = useMemo(() => new Set(), []);
- usePicker(appRef.current, canvasRef.current, pointerEvents);
-
- useLayoutEffect(() => {
-
- // Tracks if the component is unmounted while awaiting for the graphics device to be created
- let cancelled = false;
-
- (async () => {
- const canvas = canvasRef.current;
- if (!canvas || appRef.current) return;
-
- // Create the graphics device
- const dev = await internalCreateGraphicsDevice(canvas, {
- deviceTypes,
- ...graphicsOpts
- });
-
- // Check if the component unmounted while we were awaiting, and destroy the device immediately and bail out
- if (cancelled) {
- dev.destroy?.();
- return;
- }
-
- // Proceed with normal PlayCanvas init
- const pcApp = new PlayCanvasApplication(canvas, {
- mouse: new Mouse(canvas),
- touch: new TouchDevice(canvas),
- graphicsDevice: dev
- });
- pcApp.start();
-
- appRef.current = pcApp;
- setApp(pcApp);
- })();
-
- // Cleanup create a cancellation flag to avoid re-rendering the application when the component is unmounted.
- return () => {
- cancelled = true;
- if (appRef.current) {
- appRef.current.destroy();
- appRef.current = null;
- setApp(null);
- }
- };
- }, [graphicsOptsKey, deviceTypeKey]); // ← stable deps
-
- // Separate useEffect for these props to avoid re-rendering
- useEffect(() => {
- if (!app) return;
- app.setCanvasFillMode(fillMode);
- app.setCanvasResolution(resolutionMode);
- }, [app, fillMode, resolutionMode]);
-
- // These app properties can be updated without re-rendering
- useLayoutEffect(() => {
- if (!app) return;
- applyProps(app, componentDefinition.schema, otherProps as Record);
- });
-
- if (!app) return null;
-
- return (
-
-
-
-
- {children}
-
-
-
-
- );
+ const { children, ...propsToValidate } = props;
+
+ const validatedProps = validatePropsWithDefaults(
+ propsToValidate,
+ componentDefinition
+ );
+
+ const {
+ canvasRef,
+ fillMode = FILLMODE_NONE,
+ resolutionMode = RESOLUTION_AUTO,
+ usePhysics = false,
+ graphicsDeviceOptions,
+ deviceTypes = [DEVICETYPE_WEBGL2],
+ ...otherProps
+ } = validatedProps;
+
+ // Create a deviceTypes key to avoid re-rendering the application when the deviceTypes prop changes.
+ const deviceTypeKey = deviceTypes.join('-');
+
+ /**
+ * Also create a key for the graphicsDeviceOptions object to avoid
+ * re-rendering the application when the graphicsDeviceOptions prop changes.
+ * We need to sort the keys to create a stable key.
+ */
+ const graphicsOptsKey = useMemo(() => {
+ if (!graphicsDeviceOptions) return 'none';
+ return Object.entries(graphicsDeviceOptions)
+ .sort(([a], [b]) => a.localeCompare(b)) // order-insensitive
+ .map(([k, v]) => `${k}:${String(v)}`)
+ .join('|');
+ }, [graphicsDeviceOptions]);
+
+ /**
+ * Memoize the graphicsDeviceOptions object to avoid re-rendering the application when the graphicsDeviceOptions prop changes.
+ */
+ const graphicsOpts = useMemo(
+ () => ({ ...defaultGraphicsDeviceOptions, ...graphicsDeviceOptions }),
+ [graphicsDeviceOptions] // ← only changes when *values* change
+ );
+
+ const [app, setApp] = useState(null);
+ const appRef = useRef(null);
+
+ const pointerEvents = useMemo(() => new Set(), []);
+ // eslint-disable-next-line react-hooks/refs -- the picker bridges the existing imperative refs
+ usePicker(appRef.current, canvasRef.current, pointerEvents);
+
+ useLayoutEffect(() => {
+ // Tracks if the component is unmounted while awaiting for the graphics device to be created
+ let cancelled = false;
+
+ (async () => {
+ const canvas = canvasRef.current;
+ if (!canvas || appRef.current) return;
+
+ // Create the graphics device
+ const dev = await internalCreateGraphicsDevice(canvas, {
+ deviceTypes,
+ ...graphicsOpts
+ });
+
+ // Check if the component unmounted while we were awaiting, and destroy the device immediately and bail out
+ if (cancelled) {
+ dev.destroy?.();
+ return;
+ }
+
+ // Proceed with normal PlayCanvas init
+ const pcApp = new PlayCanvasApplication(canvas, {
+ mouse: new Mouse(canvas),
+ touch: new TouchDevice(canvas),
+ graphicsDevice: dev
+ });
+ pcApp.start();
+
+ appRef.current = pcApp;
+ setApp(pcApp);
+ })();
+
+ // Cleanup create a cancellation flag to avoid re-rendering the application when the component is unmounted.
+ return () => {
+ cancelled = true;
+ if (appRef.current) {
+ appRef.current.destroy();
+ appRef.current = null;
+ setApp(null);
+ }
+ };
+ }, [graphicsOptsKey, deviceTypeKey]); // ← stable deps
+
+ // Separate useEffect for these props to avoid re-rendering
+ useEffect(() => {
+ if (!app) return;
+ app.setCanvasFillMode(fillMode);
+ app.setCanvasResolution(resolutionMode);
+ }, [app, fillMode, resolutionMode]);
+
+ // These app properties can be updated without re-rendering
+ useLayoutEffect(() => {
+ if (!app) return;
+ applyProps(app, componentDefinition.schema, otherProps as Record);
+ });
+
+ if (!app) return null;
+
+ /* eslint-disable react-hooks/refs -- the providers expose the initialized engine ref */
+ return (
+
+
+
+ {children}
+
+
+
+ );
+ /* eslint-enable react-hooks/refs */
};
type CanvasProps = {
- /**
- * The class name to attach to the canvas component
- * @default pc-app
- */
- className?: string,
-
- /**
- * A style object added to the canvas component
- * @default { width: '100%', height: '100%' }
- */
- style?: Record
-}
+ /**
+ * The class name to attach to the canvas component
+ * @default pc-app
+ */
+ className?: string;
+
+ /**
+ * A style object added to the canvas component
+ * @default { width: '100%', height: '100%' }
+ */
+ style?: Record;
+};
// These props are excluded from the component props.
type ExcludedApplicationProps = 'mouse' | 'touch' | 'keyboard' | 'gamepads' | 'scene' | 'scripts' | 'assets';
-interface ApplicationProps extends Omit>, ExcludedApplicationProps>, CanvasProps {
-
- /**
- * Controls how the canvas fills the window and resizes when the window changes.
- * @default FILLMODE_NONE
- */
- fillMode?: typeof FILLMODE_NONE | typeof FILLMODE_FILL_WINDOW | typeof FILLMODE_KEEP_ASPECT
-
- /**
- * Change the resolution of the canvas, and set the way it behaves when the window is resized.
- * @default RESOLUTION_AUTO
- */
- resolutionMode?: typeof RESOLUTION_AUTO | typeof RESOLUTION_FIXED
-
- /**
- * When `true`, the PlayCanvas Physics system will be enabled.
- * @default false
- */
- usePhysics?: boolean,
-
- /**
- * The device types to use for the graphics device. This allows you to set an order of preference for the graphics device.
- * The first device type in the array that is supported by the browser will be used.
- *
- * @example
- *
- *
- * This will use the WebGPU device if it is supported, otherwise it will use the WebGL2 device.
- *
- * @default [DEVICETYPE_WEBGL2]
- */
- deviceTypes?: DeviceType[],
-
- /** Graphics Settings */
- graphicsDeviceOptions?: GraphicsDeviceOptions,
- /** The children of the application */
- children?: React.ReactNode,
-}
+/* eslint-disable @typescript-eslint/consistent-type-definitions -- interfaces preserve inherited prop overrides */
+interface ApplicationProps
+ extends Omit>, ExcludedApplicationProps>, CanvasProps {
+ /**
+ * Controls how the canvas fills the window and resizes when the window changes.
+ * @default FILLMODE_NONE
+ */
+ fillMode?: typeof FILLMODE_NONE | typeof FILLMODE_FILL_WINDOW | typeof FILLMODE_KEEP_ASPECT;
+
+ /**
+ * Change the resolution of the canvas, and set the way it behaves when the window is resized.
+ * @default RESOLUTION_AUTO
+ */
+ resolutionMode?: typeof RESOLUTION_AUTO | typeof RESOLUTION_FIXED;
+ /**
+ * When `true`, the PlayCanvas Physics system will be enabled.
+ * @default false
+ */
+ usePhysics?: boolean;
+
+ /**
+ * The device types to use for the graphics device. This allows you to set an order of preference for the graphics device.
+ * The first device type in the array that is supported by the browser will be used.
+ *
+ * @example
+ *
+ *
+ * This will use the WebGPU device if it is supported, otherwise it will use the WebGL2 device.
+ *
+ * @default [DEVICETYPE_WEBGL2]
+ */
+ deviceTypes?: DeviceType[];
+
+ /** Graphics Settings */
+ graphicsDeviceOptions?: GraphicsDeviceOptions;
+ /** The children of the application */
+ children?: React.ReactNode;
+}
interface ApplicationWithoutCanvasProps extends ApplicationProps {
- /** A ref to a html canvas element */
- canvasRef: React.RefObject;
+ /** A ref to a html canvas element */
+ canvasRef: React.RefObject;
}
+/* eslint-enable @typescript-eslint/consistent-type-definitions */
const exclude: ExcludedApplicationProps[] = ['mouse', 'touch', 'keyboard', 'gamepads', 'scene', 'scripts', 'assets'];
const componentDefinition = createComponentDefinition(
- "Application",
- () => getNullApplication(),
- (app) => app.destroy(),
- { exclude }
-)
+ 'Application',
+ () => getNullApplication(),
+ (app) => app.destroy(),
+ { exclude }
+);
componentDefinition.schema = {
- ...componentDefinition.schema,
- deviceTypes: {
- validate: (value: unknown) => Array.isArray(value) && value.every((v: unknown) => typeof v === 'string' && [DEVICETYPE_WEBGPU, DEVICETYPE_WEBGL2, DEVICETYPE_NULL].includes(v as typeof DEVICETYPE_WEBGPU | typeof DEVICETYPE_WEBGL2 | typeof DEVICETYPE_NULL)),
- errorMsg: (value: unknown) => {
- return `deviceTypes must be an array containing one or more of: '${DEVICETYPE_WEBGPU}', '${DEVICETYPE_WEBGL2}', '${DEVICETYPE_NULL}'. Received: ['${value}']`
+ ...componentDefinition.schema,
+ deviceTypes: {
+ validate: (value: unknown) =>
+ Array.isArray(value) &&
+ value.every(
+ (v: unknown) =>
+ typeof v === 'string' &&
+ [DEVICETYPE_WEBGPU, DEVICETYPE_WEBGL2, DEVICETYPE_NULL].includes(
+ v as typeof DEVICETYPE_WEBGPU | typeof DEVICETYPE_WEBGL2 | typeof DEVICETYPE_NULL
+ )
+ ),
+ errorMsg: (value: unknown) => {
+ return `deviceTypes must be an array containing one or more of: '${DEVICETYPE_WEBGPU}', '${DEVICETYPE_WEBGL2}', '${DEVICETYPE_NULL}'. Received: ['${value}']`;
+ },
+ /**
+ * In test environments, we default to a Null device, because we don't cant use WebGL2/WebGPU.
+ * This is just for testing purposes so we can test the fallback logic, without initializing WebGL2/WebGPU.
+ */
+ default: env === 'test' ? [DEVICETYPE_NULL] : [DEVICETYPE_WEBGL2]
},
- /**
- * In test environments, we default to a Null device, because we don't cant use WebGL2/WebGPU.
- * This is just for testing purposes so we can test the fallback logic, without initializing WebGL2/WebGPU.
- */
- default: env === 'test' ? [DEVICETYPE_NULL] : [DEVICETYPE_WEBGL2]
- },
- className: {
- validate: (value: unknown) => typeof value === 'string',
- errorMsg: (value: unknown) => `className must be a string. Received: ${value}`,
- default: 'pc-app'
- },
- style: {
- validate: (value: unknown) => typeof value === 'object' && value !== null,
- errorMsg: (value: unknown) => `style must be an object. Received: ${value}`,
- default: { width: '100%', height: '100%' }
- },
- canvasRef: {
- validate: (value: unknown) => value !== null &&
- typeof value === 'object' &&
- 'current' in value,
- errorMsg: (value: unknown) => `canvasRef must be a React ref object. Received: ${value}`,
- default: null
- },
- usePhysics: {
- validate: (value: unknown) => typeof value === 'boolean',
- errorMsg: (value: unknown) => `usePhysics must be a boolean. Received: ${value}`,
- default: false
- },
- fillMode: {
- validate: (value: unknown) => typeof value === 'string' && [FILLMODE_NONE, FILLMODE_FILL_WINDOW, FILLMODE_KEEP_ASPECT].includes(value as typeof FILLMODE_NONE | typeof FILLMODE_FILL_WINDOW | typeof FILLMODE_KEEP_ASPECT),
- errorMsg: () => `"fillMode" must be one of: ${FILLMODE_NONE}, ${FILLMODE_FILL_WINDOW}, ${FILLMODE_KEEP_ASPECT}`,
- default: FILLMODE_NONE
- },
- resolutionMode: {
- validate: (value: unknown) => typeof value === 'string' && [RESOLUTION_AUTO, RESOLUTION_FIXED].includes(value as typeof RESOLUTION_AUTO | typeof RESOLUTION_FIXED),
- errorMsg: () => `"resolutionMode" must be one of: ${RESOLUTION_AUTO}, ${RESOLUTION_FIXED}`,
- default: RESOLUTION_AUTO
- },
- graphicsDeviceOptions: {
- validate: (value: unknown) => value === undefined || (typeof value === 'object' && value !== null),
- errorMsg: (value: unknown) => `graphicsDeviceOptions must be an object. Received: ${value}`,
- default: undefined
- }
-} as Schema
\ No newline at end of file
+ className: {
+ validate: (value: unknown) => typeof value === 'string',
+ errorMsg: (value: unknown) => `className must be a string. Received: ${value}`,
+ default: 'pc-app'
+ },
+ style: {
+ validate: (value: unknown) => typeof value === 'object' && value !== null,
+ errorMsg: (value: unknown) => `style must be an object. Received: ${value}`,
+ default: { width: '100%', height: '100%' }
+ },
+ canvasRef: {
+ validate: (value: unknown) => value !== null && typeof value === 'object' && 'current' in value,
+ errorMsg: (value: unknown) => `canvasRef must be a React ref object. Received: ${value}`,
+ default: null
+ },
+ usePhysics: {
+ validate: (value: unknown) => typeof value === 'boolean',
+ errorMsg: (value: unknown) => `usePhysics must be a boolean. Received: ${value}`,
+ default: false
+ },
+ fillMode: {
+ validate: (value: unknown) =>
+ typeof value === 'string' &&
+ [FILLMODE_NONE, FILLMODE_FILL_WINDOW, FILLMODE_KEEP_ASPECT].includes(
+ value as typeof FILLMODE_NONE | typeof FILLMODE_FILL_WINDOW | typeof FILLMODE_KEEP_ASPECT
+ ),
+ errorMsg: () => `"fillMode" must be one of: ${FILLMODE_NONE}, ${FILLMODE_FILL_WINDOW}, ${FILLMODE_KEEP_ASPECT}`,
+ default: FILLMODE_NONE
+ },
+ resolutionMode: {
+ validate: (value: unknown) =>
+ typeof value === 'string' &&
+ [RESOLUTION_AUTO, RESOLUTION_FIXED].includes(value as typeof RESOLUTION_AUTO | typeof RESOLUTION_FIXED),
+ errorMsg: () => `"resolutionMode" must be one of: ${RESOLUTION_AUTO}, ${RESOLUTION_FIXED}`,
+ default: RESOLUTION_AUTO
+ },
+ graphicsDeviceOptions: {
+ validate: (value: unknown) => value === undefined || (typeof value === 'object' && value !== null),
+ errorMsg: (value: unknown) => `graphicsDeviceOptions must be an object. Received: ${value}`,
+ default: undefined
+ }
+} as Schema;
diff --git a/packages/lib/src/Container.test.tsx b/packages/lib/src/Container.test.tsx
index 5e433c18..07c97511 100644
--- a/packages/lib/src/Container.test.tsx
+++ b/packages/lib/src/Container.test.tsx
@@ -1,127 +1,122 @@
import { render, screen } from '@testing-library/react';
-import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
-import { Container } from './Container.tsx';
import { Asset, Entity } from 'playcanvas';
-import { ReactNode } from 'react';
-import { Application } from './Application.tsx';
+import type { ReactNode } from 'react';
import React from 'react';
+import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
+
+import { Application } from './Application.tsx';
+import { Container } from './Container.tsx';
const renderWithProviders = (ui: ReactNode) => {
- return render(
-
- {ui}
-
- );
+ return render({ui});
};
describe('Container', () => {
- let mockAsset: Asset;
- let mockEntity: Entity;
-
- beforeEach(() => {
- vi.stubEnv('NODE_ENV', 'development');
-
- // Create a real Entity for the asset to return
- mockEntity = new Entity();
-
- // Create a real Asset with a mock resource
- mockAsset = new Asset('test-container', 'container', { url: 'test.container', filename: 'test.container' });
- mockAsset.resource = {
- instantiateRenderEntity: vi.fn().mockReturnValue(mockEntity)
- };
- });
-
- afterEach(() => {
- vi.unstubAllEnvs();
- vi.clearAllMocks();
- mockEntity.destroy();
- });
-
- it.skip('renders with default props', () => {
- const { app } = renderWithProviders(
-
- Test Content
-
- );
-
- const container = screen.getByTestId('container');
- expect(container).toBeInTheDocument();
- expect(container).toHaveClass('playcanvas-container');
-
- // Verify the entity was added to the app
- expect(app.root.children).toContain(mockEntity);
- });
-
- it.skip('renders children correctly', () => {
- renderWithProviders(
-
- Test Content
-
- );
-
- const child = screen.getByTestId('child');
- expect(child).toBeInTheDocument();
- expect(child).toHaveTextContent('Test Content');
- });
-
- it.skip('applies custom className', () => {
- renderWithProviders(
-
- Test Content
-
- );
-
- const container = screen.getByTestId('container');
- expect(container).toHaveClass('playcanvas-container', 'custom-class');
- });
-
- it.skip('applies custom styles', () => {
- const customStyles = { backgroundColor: 'red' };
- renderWithProviders(
-
- Test Content
-
- );
-
- const container = screen.getByTestId('container');
- expect(container).toHaveStyle({ backgroundColor: 'red' });
- });
-
- it.skip('handles click events', () => {
- const handleClick = vi.fn();
- renderWithProviders(
-
- Test Content
-
- );
-
- const container = screen.getByTestId('container');
- container.click();
- expect(handleClick).toHaveBeenCalled();
- });
-
- it.skip('validates prop types in development', () => {
- const consoleSpy = vi.spyOn(console, 'warn');
- renderWithProviders(
-
- Test Content
-
- );
-
- expect(consoleSpy).toHaveBeenCalledWith(
- expect.stringContaining('Invalid prop `className` of type `number`')
- );
- });
-
- it.skip('cleans up resources on unmount', () => {
- const { unmount } = renderWithProviders(
-
- Test Content
-
- );
-
- const destroySpy = vi.spyOn(mockEntity, 'destroy');
- unmount();
- expect(destroySpy).toHaveBeenCalled();
- });
-});
\ No newline at end of file
+ let mockAsset: Asset;
+ let mockEntity: Entity;
+
+ beforeEach(() => {
+ vi.stubEnv('NODE_ENV', 'development');
+
+ // Create a real Entity for the asset to return
+ mockEntity = new Entity();
+
+ // Create a real Asset with a mock resource
+ mockAsset = new Asset('test-container', 'container', { url: 'test.container', filename: 'test.container' });
+ mockAsset.resource = {
+ instantiateRenderEntity: vi.fn().mockReturnValue(mockEntity)
+ };
+ });
+
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ vi.clearAllMocks();
+ mockEntity.destroy();
+ });
+
+ it.skip('renders with default props', () => {
+ const { app } = renderWithProviders(
+
+ Test Content
+
+ );
+
+ const container = screen.getByTestId('container');
+ expect(container).toBeInTheDocument();
+ expect(container).toHaveClass('playcanvas-container');
+
+ // Verify the entity was added to the app
+ expect(app.root.children).toContain(mockEntity);
+ });
+
+ it.skip('renders children correctly', () => {
+ renderWithProviders(
+
+ Test Content
+
+ );
+
+ const child = screen.getByTestId('child');
+ expect(child).toBeInTheDocument();
+ expect(child).toHaveTextContent('Test Content');
+ });
+
+ it.skip('applies custom className', () => {
+ renderWithProviders(
+
+ Test Content
+
+ );
+
+ const container = screen.getByTestId('container');
+ expect(container).toHaveClass('playcanvas-container', 'custom-class');
+ });
+
+ it.skip('applies custom styles', () => {
+ const customStyles = { backgroundColor: 'red' };
+ renderWithProviders(
+
+ Test Content
+
+ );
+
+ const container = screen.getByTestId('container');
+ expect(container).toHaveStyle({ backgroundColor: 'red' });
+ });
+
+ it.skip('handles click events', () => {
+ const handleClick = vi.fn();
+ renderWithProviders(
+
+ Test Content
+
+ );
+
+ const container = screen.getByTestId('container');
+ container.click();
+ expect(handleClick).toHaveBeenCalled();
+ });
+
+ it.skip('validates prop types in development', () => {
+ const consoleSpy = vi.spyOn(console, 'warn');
+ renderWithProviders(
+
+ Test Content
+
+ );
+
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid prop `className` of type `number`'));
+ });
+
+ it.skip('cleans up resources on unmount', () => {
+ const { unmount } = renderWithProviders(
+
+ Test Content
+
+ );
+
+ const destroySpy = vi.spyOn(mockEntity, 'destroy');
+ unmount();
+ expect(destroySpy).toHaveBeenCalled();
+ });
+});
diff --git a/packages/lib/src/Container.tsx b/packages/lib/src/Container.tsx
index 0112c6db..64673602 100644
--- a/packages/lib/src/Container.tsx
+++ b/packages/lib/src/Container.tsx
@@ -1,8 +1,11 @@
-import { FC, useLayoutEffect, useRef } from "react";
-import { useApp } from "./hooks/index.ts";
-import { Asset, Entity as PcEntity } from "playcanvas";
-import { Entity } from "./Entity.tsx";
-import { GlbContainerResource } from "playcanvas/build/playcanvas/src/framework/parsers/glb-container-resource.js";
+import type { Asset, Entity as PcEntity } from 'playcanvas';
+import type { GlbContainerResource } from 'playcanvas/build/playcanvas/src/framework/parsers/glb-container-resource.js';
+import type { FC } from 'react';
+import { useLayoutEffect, useRef } from 'react';
+
+import { Entity } from './Entity.tsx';
+import { useApp } from './hooks/index.ts';
+// eslint-disable-next-line @typescript-eslint/consistent-type-definitions -- preserve the existing index signature
interface ContainerProps {
asset: Asset;
children?: React.ReactNode;
@@ -17,7 +20,6 @@ interface ContainerProps {
* @returns {React.ReactNode} - The rendered container.
*/
export const Container: FC = ({ asset, children, ...props }) => {
-
const entityRef = useRef(null);
const assetEntityRef = useRef(null);
const app = useApp();
@@ -32,20 +34,21 @@ export const Container: FC = ({ asset, children, ...props }) =>
return () => {
if (!entityRef.current || !assetEntityRef.current) return;
-
+
// Don't destroy the underlying resource as it may be used by other components
assetEntityRef.current.destroy();
entityRef.current.removeChild(assetEntityRef.current);
entityRef.current = null;
assetEntityRef.current = null;
-
};
}, [app, parent, asset, asset?.resource]);
- if(!asset?.resource) return null;
+ if (!asset?.resource) return null;
- return
- { children }
- ;
+ return (
+
+ {children}
+
+ );
};
diff --git a/packages/lib/src/Entity.test.tsx b/packages/lib/src/Entity.test.tsx
index 0feef98f..4ffdff42 100644
--- a/packages/lib/src/Entity.test.tsx
+++ b/packages/lib/src/Entity.test.tsx
@@ -1,135 +1,125 @@
import { render, screen } from '@testing-library/react';
-import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
-import { Entity } from './Entity.tsx';
-import { ParentContext } from './hooks/index.ts';
-import { ReactNode, useContext } from 'react';
import { Entity as PcEntity } from 'playcanvas';
+import type { ReactNode } from 'react';
+import React, { useContext } from 'react';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+
import { TEST_ENTITY_PROPS } from '../test/constants.ts';
+
import { Application } from './Application.tsx';
-import React from 'react';
+import { Entity } from './Entity.tsx';
+import { ParentContext } from './hooks/index.ts';
const renderWithProviders = (ui: ReactNode) => {
- return render(
-
- {ui}
-
- );
+ return render({ui});
};
describe('Entity', () => {
- beforeEach(() => {
- vi.clearAllMocks();
- vi.stubEnv('NODE_ENV', 'development');
- });
-
- afterEach(() => {
- vi.unstubAllEnvs();
- });
-
- it.skip('renders with default props', () => {
- const { app } = renderWithProviders();
-
- // Verify that the entity was created and added to the app
- expect(app.root.children.length).toBe(1);
- const entity = app.root.children[0];
- expect(entity).toBeInstanceOf(PcEntity);
- });
-
- it.skip('applies custom props correctly', () => {
- const { app } = renderWithProviders();
-
- const entity = app.root.children[0];
- expect(entity.name).toBe(TEST_ENTITY_PROPS.name);
-
- const pos = entity.getLocalPosition();
- expect([pos.x, pos.y, pos.z]).toEqual(TEST_ENTITY_PROPS.position);
-
- const scale = entity.getLocalScale();
- expect([scale.x, scale.y, scale.z]).toEqual(TEST_ENTITY_PROPS.scale);
-
- const rot = entity.getLocalEulerAngles();
- expect([rot.x, rot.y, rot.z]).toEqual(TEST_ENTITY_PROPS.rotation);
- });
-
- it.skip('handles pointer events correctly', () => {
- const onPointerDown = vi.fn();
- const onPointerUp = vi.fn();
- const onPointerOver = vi.fn();
- const onPointerOut = vi.fn();
- const onClick = vi.fn();
-
- const { app } = renderWithProviders(
-
- );
-
- const entity = app.root.children[0];
-
- // Simulate events
- entity.fire('pointerdown');
- expect(onPointerDown).toHaveBeenCalled();
-
- entity.fire('pointerup');
- expect(onPointerUp).toHaveBeenCalled();
-
- entity.fire('pointerover');
- expect(onPointerOver).toHaveBeenCalled();
-
- entity.fire('pointerout');
- expect(onPointerOut).toHaveBeenCalled();
-
- entity.fire('click');
- expect(onClick).toHaveBeenCalled();
- });
-
- it.skip('provides parent context to children', () => {
- const ChildComponent = () => {
- const parent = useContext(ParentContext);
- return {parent ? 'Has parent' : 'No parent'} ;
- };
-
- renderWithProviders(
-
-
-
- );
-
- expect(screen.getByTestId('child').textContent).toBe('Has parent');
- });
-
- it.skip('cleans up resources on unmount', () => {
- const { app, unmount } = renderWithProviders();
- const entity = app.root.children[0];
- const destroySpy = vi.spyOn(entity, 'destroy');
-
- unmount();
-
- expect(destroySpy).toHaveBeenCalled();
- expect(app.root.children.length).toBe(0);
- });
-
- it.skip('validates prop types correctly', async () => {
- const consoleSpy = vi.spyOn(console, 'warn');
-
- renderWithProviders(
-
- );
-
- expect(consoleSpy).toHaveBeenCalledWith(
- expect.stringContaining('Invalid value for prop "position"')
- );
-
- renderWithProviders(
-
- );
-
- expect(consoleSpy).toHaveBeenCalledWith(
- expect.stringContaining('Invalid value for prop "rotation"')
- );
- });
-});
\ No newline at end of file
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.stubEnv('NODE_ENV', 'development');
+ });
+
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ });
+
+ it.skip('renders with default props', () => {
+ const { app } = renderWithProviders();
+
+ // Verify that the entity was created and added to the app
+ expect(app.root.children.length).toBe(1);
+ const entity = app.root.children[0];
+ expect(entity).toBeInstanceOf(PcEntity);
+ });
+
+ it.skip('applies custom props correctly', () => {
+ const { app } = renderWithProviders();
+
+ const entity = app.root.children[0];
+ expect(entity.name).toBe(TEST_ENTITY_PROPS.name);
+
+ const pos = entity.getLocalPosition();
+ expect([pos.x, pos.y, pos.z]).toEqual(TEST_ENTITY_PROPS.position);
+
+ const scale = entity.getLocalScale();
+ expect([scale.x, scale.y, scale.z]).toEqual(TEST_ENTITY_PROPS.scale);
+
+ const rot = entity.getLocalEulerAngles();
+ expect([rot.x, rot.y, rot.z]).toEqual(TEST_ENTITY_PROPS.rotation);
+ });
+
+ it.skip('handles pointer events correctly', () => {
+ const onPointerDown = vi.fn();
+ const onPointerUp = vi.fn();
+ const onPointerOver = vi.fn();
+ const onPointerOut = vi.fn();
+ const onClick = vi.fn();
+
+ const { app } = renderWithProviders(
+
+ );
+
+ const entity = app.root.children[0];
+
+ // Simulate events
+ entity.fire('pointerdown');
+ expect(onPointerDown).toHaveBeenCalled();
+
+ entity.fire('pointerup');
+ expect(onPointerUp).toHaveBeenCalled();
+
+ entity.fire('pointerover');
+ expect(onPointerOver).toHaveBeenCalled();
+
+ entity.fire('pointerout');
+ expect(onPointerOut).toHaveBeenCalled();
+
+ entity.fire('click');
+ expect(onClick).toHaveBeenCalled();
+ });
+
+ it.skip('provides parent context to children', () => {
+ const ChildComponent = () => {
+ const parent = useContext(ParentContext);
+ return {parent ? 'Has parent' : 'No parent'} ;
+ };
+
+ renderWithProviders(
+
+
+
+ );
+
+ expect(screen.getByTestId('child').textContent).toBe('Has parent');
+ });
+
+ it.skip('cleans up resources on unmount', () => {
+ const { app, unmount } = renderWithProviders();
+ const entity = app.root.children[0];
+ const destroySpy = vi.spyOn(entity, 'destroy');
+
+ unmount();
+
+ expect(destroySpy).toHaveBeenCalled();
+ expect(app.root.children.length).toBe(0);
+ });
+
+ it.skip('validates prop types correctly', async () => {
+ const consoleSpy = vi.spyOn(console, 'warn');
+
+ renderWithProviders();
+
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid value for prop "position"'));
+
+ renderWithProviders();
+
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid value for prop "rotation"'));
+ });
+});
diff --git a/packages/lib/src/Entity.tsx b/packages/lib/src/Entity.tsx
index 43d7f8f8..d09eacc3 100644
--- a/packages/lib/src/Entity.tsx
+++ b/packages/lib/src/Entity.tsx
@@ -1,12 +1,15 @@
-"use client"
+'use client';
import { Entity as PcEntity } from 'playcanvas';
-import { ReactNode, forwardRef, useImperativeHandle, useLayoutEffect, useMemo } from 'react';
-import { useParent, ParentContext, useApp } from './hooks/index.ts';
-import { SyntheticMouseEvent, SyntheticPointerEvent } from './utils/synthetic-event.ts';
+import type { ReactNode } from 'react';
+import { forwardRef, useImperativeHandle, useLayoutEffect, useMemo } from 'react';
+
import { usePointerEventsContext } from './contexts/pointer-events-context.tsx';
-import { PublicProps } from './utils/types-utils.ts';
-import { validatePropsWithDefaults, ComponentDefinition } from './utils/validation.ts';
+import { useParent, ParentContext, useApp } from './hooks/index.ts';
+import type { SyntheticMouseEvent, SyntheticPointerEvent } from './utils/synthetic-event.ts';
+import type { PublicProps } from './utils/types-utils.ts';
+import type { ComponentDefinition } from './utils/validation.ts';
+import { validatePropsWithDefaults } from './utils/validation.ts';
/**
* The Entity component is the fundamental building block of a PlayCanvas scene.
@@ -17,241 +20,235 @@ import { validatePropsWithDefaults, ComponentDefinition } from './utils/validati
*
*
*
- *
+ *
* @example
* // With pointer events
- * console.log('Clicked!')}
* onClick={(e) => console.log('Mouse clicked!')}
* >
*
*
- *
+ *
* @param {EntityProps} props - Component props
*/
-export const Entity = forwardRef (function Entity(
- props,
- ref
-) : React.ReactElement | null {
-
- const { children, ...propsToValidate } = props;
- const safeProps = validatePropsWithDefaults(
- propsToValidate,
- componentDefinition as ComponentDefinition
- );
-
- const {
- /** The name of the entity */
- name = 'Untitled',
- /** The local position of the entity */
- position = [0, 0, 0],
- /** The local scale of the entity */
- scale = [1, 1, 1],
- /** The local rotation of the entity */
- rotation = [0, 0, 0],
- /** The callback for the pointer down event */
- onPointerDown,
- /** The callback for the pointer up event */
- onPointerUp,
- /** The callback for the pointer over event */
- onPointerOver,
- /** The callback for the pointer out event */
- onPointerOut,
- /** The callback for the click event */
- onClick,
- } : EntityProps = safeProps;
-
- const parent = useParent();
- const app = useApp();
- const pointerEvents = usePointerEventsContext();
-
- // Check if the entity has pointer events attached
- const hasPointerEvents = !!(onPointerDown || onPointerUp || onPointerOver || onPointerOut || onClick);
-
- // Helper function to apply entity properties
- const applyEntityProperties = (e: PcEntity) => {
- e.name = name;
- e.setLocalPosition(...position as [number, number, number]);
- e.setLocalScale(...scale as [number, number, number]);
- e.setLocalEulerAngles(...rotation as [number, number, number]);
- };
-
- // Create the entity only when 'app' changes
- const entity = useMemo(() => {
- const e = new PcEntity(undefined, app);
- applyEntityProperties(e);
- return e;
- }, [app]) as PcEntity;
-
- useImperativeHandle(ref, () => entity);
-
- // Add entity to parent when 'entity' or 'parent' changes
- useLayoutEffect(() => {
- parent.addChild(entity);
-
- return () => {
- parent.removeChild(entity);
- entity.destroy(); // Clean up the entity
+export const Entity = forwardRef(function Entity(props, ref): React.ReactElement | null {
+ const { children, ...propsToValidate } = props;
+ const safeProps = validatePropsWithDefaults(
+ propsToValidate,
+ componentDefinition as ComponentDefinition
+ );
+
+ const {
+ /** The name of the entity */
+ name = 'Untitled',
+ /** The local position of the entity */
+ position = [0, 0, 0],
+ /** The local scale of the entity */
+ scale = [1, 1, 1],
+ /** The local rotation of the entity */
+ rotation = [0, 0, 0],
+ /** The callback for the pointer down event */
+ onPointerDown,
+ /** The callback for the pointer up event */
+ onPointerUp,
+ /** The callback for the pointer over event */
+ onPointerOver,
+ /** The callback for the pointer out event */
+ onPointerOut,
+ /** The callback for the click event */
+ onClick
+ }: EntityProps = safeProps;
+
+ const parent = useParent();
+ const app = useApp();
+ const pointerEvents = usePointerEventsContext();
+
+ // Check if the entity has pointer events attached
+ const hasPointerEvents = !!(onPointerDown || onPointerUp || onPointerOver || onPointerOut || onClick);
+
+ // Helper function to apply entity properties
+ const applyEntityProperties = (e: PcEntity) => {
+ e.name = name;
+ e.setLocalPosition(...(position as [number, number, number]));
+ e.setLocalScale(...(scale as [number, number, number]));
+ e.setLocalEulerAngles(...(rotation as [number, number, number]));
};
- }, [app, parent, entity]);
-
-
- // PointerEvents
- useLayoutEffect(() => {
- if (hasPointerEvents) {
- pointerEvents.add(entity.getGuid());
- }
-
- if (onPointerDown) entity.on('pointerdown', onPointerDown);
- if (onPointerUp) entity.on('pointerup', onPointerUp);
- if (onPointerOver) entity.on('pointerover', onPointerOver);
- if (onPointerOut) entity.on('pointerout', onPointerOut);
- if (onClick) entity.on('click', onClick);
-
- return () => {
- if (hasPointerEvents) {
- pointerEvents.delete(entity.getGuid());
- }
- if (onPointerDown) entity.off('pointerdown', onPointerDown);
- if (onPointerUp) entity.off('pointerup', onPointerUp);
- if (onPointerOver) entity.off('pointerover', onPointerOver);
- if (onPointerOut) entity.off('pointerout', onPointerOut);
- if (onClick) entity.off('click', onClick);
- }
-
- }, [app, parent, entity, onPointerDown, onPointerUp, onPointerOver, onPointerOut, onClick]);
-
- // Update entity properties when they change
- useLayoutEffect(() => {
- applyEntityProperties(entity);
- }, [entity, name, position, scale, rotation]);
-
- return (<>
-
- {children || null}
-
- > );
+ // Create the entity only when 'app' changes
+ const entity = useMemo(() => {
+ const e = new PcEntity(undefined, app);
+ applyEntityProperties(e);
+ return e;
+ }, [app]) as PcEntity;
+
+ useImperativeHandle(ref, () => entity);
+
+ // Add entity to parent when 'entity' or 'parent' changes
+ useLayoutEffect(() => {
+ parent.addChild(entity);
+
+ return () => {
+ parent.removeChild(entity);
+ entity.destroy(); // Clean up the entity
+ };
+ }, [app, parent, entity]);
+
+ // PointerEvents
+ useLayoutEffect(() => {
+ if (hasPointerEvents) {
+ pointerEvents.add(entity.getGuid());
+ }
+
+ if (onPointerDown) entity.on('pointerdown', onPointerDown);
+ if (onPointerUp) entity.on('pointerup', onPointerUp);
+ if (onPointerOver) entity.on('pointerover', onPointerOver);
+ if (onPointerOut) entity.on('pointerout', onPointerOut);
+ if (onClick) entity.on('click', onClick);
+
+ return () => {
+ if (hasPointerEvents) {
+ pointerEvents.delete(entity.getGuid());
+ }
+ if (onPointerDown) entity.off('pointerdown', onPointerDown);
+ if (onPointerUp) entity.off('pointerup', onPointerUp);
+ if (onPointerOver) entity.off('pointerover', onPointerOver);
+ if (onPointerOut) entity.off('pointerout', onPointerOut);
+ if (onClick) entity.off('click', onClick);
+ };
+ }, [app, parent, entity, onPointerDown, onPointerUp, onPointerOver, onPointerOut, onClick]);
+
+ // Update entity properties when they change
+ useLayoutEffect(() => {
+ applyEntityProperties(entity);
+ }, [entity, name, position, scale, rotation]);
+
+ return (
+ <>
+ {children || null}
+ >
+ );
});
type PointerEventCallback = (event: SyntheticPointerEvent) => void;
type MouseEventCallback = (event: SyntheticMouseEvent) => void;
+// eslint-disable-next-line @typescript-eslint/consistent-type-definitions -- preserve the public inherited prop override
export interface EntityProps extends Partial> {
- /**
- * The name of the entity
- * @default "Untitled"
- */
- name?: string;
-
- /**
- * The local position of the entity relative to its parent.
- * You can use the `position` prop to set the position of the entity.
- * @default [0, 0, 0]
- */
- position?: [number, number, number];
-
- /**
- * The local scale of the entity relative to its parent.
- * You can use the `scale` prop to set the scale of the entity.
- * @default [1, 1, 1]
- */
- scale?: [number, number, number];
-
- /**
- * The local rotation of the entity relative to its parent.
- * The rotation is specified as euler angles in degrees.
- * @default [0, 0, 0]
- */
- rotation?: [number, number, number];
-
- /**
- * The callback for the pointer up event
- * @param {SyntheticPointerEvent} event - The pointer up event
- * @default null
- */
- onPointerUp?: PointerEventCallback;
-
- /**
- * The callback for the pointer down event
- * @param {SyntheticPointerEvent} event - The pointer down event
- * @default null
- */
- onPointerDown?: PointerEventCallback;
-
- /**
- * The callback for the pointer over event
- * @param {SyntheticPointerEvent} event - The pointer over event
- * @default null
- */
- onPointerOver?: PointerEventCallback;
-
- /**
- * The callback for the pointer out event
- * @param {SyntheticPointerEvent} event - The pointer out event
- * @default null
- */
- onPointerOut?: PointerEventCallback;
-
- /**
- * The callback for the click event
- * @param {SyntheticMouseEvent} event - The click event
- * @default null
- */
- onClick?: MouseEventCallback;
-
- children?: ReactNode;
+ /**
+ * The name of the entity
+ * @default "Untitled"
+ */
+ name?: string;
+
+ /**
+ * The local position of the entity relative to its parent.
+ * You can use the `position` prop to set the position of the entity.
+ * @default [0, 0, 0]
+ */
+ position?: [number, number, number];
+
+ /**
+ * The local scale of the entity relative to its parent.
+ * You can use the `scale` prop to set the scale of the entity.
+ * @default [1, 1, 1]
+ */
+ scale?: [number, number, number];
+
+ /**
+ * The local rotation of the entity relative to its parent.
+ * The rotation is specified as euler angles in degrees.
+ * @default [0, 0, 0]
+ */
+ rotation?: [number, number, number];
+
+ /**
+ * The callback for the pointer up event
+ * @param {SyntheticPointerEvent} event - The pointer up event
+ * @default null
+ */
+ onPointerUp?: PointerEventCallback;
+
+ /**
+ * The callback for the pointer down event
+ * @param {SyntheticPointerEvent} event - The pointer down event
+ * @default null
+ */
+ onPointerDown?: PointerEventCallback;
+
+ /**
+ * The callback for the pointer over event
+ * @param {SyntheticPointerEvent} event - The pointer over event
+ * @default null
+ */
+ onPointerOver?: PointerEventCallback;
+
+ /**
+ * The callback for the pointer out event
+ * @param {SyntheticPointerEvent} event - The pointer out event
+ * @default null
+ */
+ onPointerOut?: PointerEventCallback;
+
+ /**
+ * The callback for the click event
+ * @param {SyntheticMouseEvent} event - The click event
+ * @default null
+ */
+ onClick?: MouseEventCallback;
+
+ children?: ReactNode;
}
const componentDefinition = {
- name: "Entity",
- apiName: "Entity",
- schema: {
- name: {
- validate: (val: unknown) => !val || typeof val === 'string',
- errorMsg: (val: unknown) => `Invalid value for prop "name": "${val}". Expected a string or undefined.`,
- default: 'Untitled'
- },
- position: {
- validate: (val: unknown) => Array.isArray(val) && val.length === 3,
- errorMsg: (val: unknown) => `Invalid value for prop "position": "${val}". Expected an array of 3 numbers.`,
- default: [0, 0, 0]
- },
- scale: {
- validate: (val: unknown) => Array.isArray(val) && val.length === 3,
- errorMsg: (val: unknown) => `Invalid value for prop "scale": "${val}". Expected an array of 3 numbers.`,
- default: [1, 1, 1]
- },
- rotation: {
- validate: (val: unknown) => Array.isArray(val) && val.length === 3,
- errorMsg: (val: unknown) => `Invalid value for prop "rotation": "${val}". Expected an array of 3 numbers.`,
- default: [0, 0, 0]
- },
- onPointerDown: {
- validate: (val: unknown) => typeof val === 'function',
- errorMsg: (val: unknown) => `Invalid value for prop "onPointerDown": "${val}". Expected a function.`,
- default: undefined
- },
- onPointerUp: {
- validate: (val: unknown) => typeof val === 'function',
- errorMsg: (val: unknown) => `Invalid value for prop "onPointerUp": "${val}". Expected a function.`,
- default: undefined
- },
- onPointerOver: {
- validate: (val: unknown) => typeof val === 'function',
- errorMsg: (val: unknown) => `Invalid value for prop "onPointerOver": "${val}". Expected a function.`,
- default: undefined
- },
- onPointerOut: {
- validate: (val: unknown) => typeof val === 'function',
- errorMsg: (val: unknown) => `Invalid value for prop "onPointerOut": "${val}". Expected a function.`,
- default: undefined
- },
- onClick: {
- validate: (val: unknown) => typeof val === 'function',
- errorMsg: (val: unknown) => `Invalid value for prop "onClick": "${val}". Expected a function.`,
- default: undefined
+ name: 'Entity',
+ apiName: 'Entity',
+ schema: {
+ name: {
+ validate: (val: unknown) => !val || typeof val === 'string',
+ errorMsg: (val: unknown) => `Invalid value for prop "name": "${val}". Expected a string or undefined.`,
+ default: 'Untitled'
+ },
+ position: {
+ validate: (val: unknown) => Array.isArray(val) && val.length === 3,
+ errorMsg: (val: unknown) => `Invalid value for prop "position": "${val}". Expected an array of 3 numbers.`,
+ default: [0, 0, 0]
+ },
+ scale: {
+ validate: (val: unknown) => Array.isArray(val) && val.length === 3,
+ errorMsg: (val: unknown) => `Invalid value for prop "scale": "${val}". Expected an array of 3 numbers.`,
+ default: [1, 1, 1]
+ },
+ rotation: {
+ validate: (val: unknown) => Array.isArray(val) && val.length === 3,
+ errorMsg: (val: unknown) => `Invalid value for prop "rotation": "${val}". Expected an array of 3 numbers.`,
+ default: [0, 0, 0]
+ },
+ onPointerDown: {
+ validate: (val: unknown) => typeof val === 'function',
+ errorMsg: (val: unknown) => `Invalid value for prop "onPointerDown": "${val}". Expected a function.`,
+ default: undefined
+ },
+ onPointerUp: {
+ validate: (val: unknown) => typeof val === 'function',
+ errorMsg: (val: unknown) => `Invalid value for prop "onPointerUp": "${val}". Expected a function.`,
+ default: undefined
+ },
+ onPointerOver: {
+ validate: (val: unknown) => typeof val === 'function',
+ errorMsg: (val: unknown) => `Invalid value for prop "onPointerOver": "${val}". Expected a function.`,
+ default: undefined
+ },
+ onPointerOut: {
+ validate: (val: unknown) => typeof val === 'function',
+ errorMsg: (val: unknown) => `Invalid value for prop "onPointerOut": "${val}". Expected a function.`,
+ default: undefined
+ },
+ onClick: {
+ validate: (val: unknown) => typeof val === 'function',
+ errorMsg: (val: unknown) => `Invalid value for prop "onClick": "${val}". Expected a function.`,
+ default: undefined
+ }
}
- }
-}
\ No newline at end of file
+};
diff --git a/packages/lib/src/components/Align.tsx b/packages/lib/src/components/Align.tsx
index 72d44b1e..534e1b7d 100644
--- a/packages/lib/src/components/Align.tsx
+++ b/packages/lib/src/components/Align.tsx
@@ -1,10 +1,13 @@
-"use client"
+'use client';
-import { BoundingBox, Entity as PcEntity, RenderComponent, Vec3, Application, Mat4 } from "playcanvas";
-import { Children, useLayoutEffect, useRef, useState } from "react";
-import { Entity } from "../Entity.tsx";
-import { useApp, useParent } from "../hooks/index.ts";
+import type { Entity as PcEntity, RenderComponent, Application } from 'playcanvas';
+import { BoundingBox, Vec3, Mat4 } from 'playcanvas';
+import { Children, useLayoutEffect, useRef, useState } from 'react';
+import { Entity } from '../Entity.tsx';
+import { useApp, useParent } from '../hooks/index.ts';
+
+// eslint-disable-next-line @typescript-eslint/consistent-type-definitions -- preserve the existing component declaration
interface AlignProps {
left?: boolean;
right?: boolean;
@@ -17,57 +20,59 @@ interface AlignProps {
export const Align = (props: AlignProps) => {
const { left, right, top, bottom, front, back, children } = props;
-
+
const containerRef = useRef(null);
const boundsRef = useRef(new BoundingBox(new Vec3(), new Vec3()));
const [, setBounds] = useState(null);
const app: Application = useApp();
const parent: PcEntity = useParent();
-
+
// all children should be part of the scene hierarchy now
useLayoutEffect(() => {
- if (!app) return;
-
- const entity: PcEntity | null = containerRef.current;
- const bounds: BoundingBox = boundsRef.current;
-
- if (!entity) return;
-
- bounds.center.set(0, 0, 0);
- bounds.halfExtents.set(0.0, 0.0, 0.0);
-
- const tmpAABB: BoundingBox = new BoundingBox();
- const invWorldTransform: Mat4 = new Mat4();
-
- const renderComponents = entity.findComponents("render") as RenderComponent[];
-
- // Compute the bounds of all render components
- const updatedBounds = renderComponents.reduce((bounds, component) => {
- const meshInstances = component.meshInstances;
- meshInstances.forEach((mi) => {
- invWorldTransform.copy(mi.node.getWorldTransform()).invert();
- tmpAABB.setFromTransformedAabb(mi.aabb, invWorldTransform);
- bounds.add(mi.aabb);
- });
- return bounds;
- }, bounds);
-
- boundsRef.current = updatedBounds;
- setBounds(updatedBounds);
+ if (!app) return;
+
+ const entity: PcEntity | null = containerRef.current;
+ const bounds: BoundingBox = boundsRef.current;
+
+ if (!entity) return;
+
+ bounds.center.set(0, 0, 0);
+ bounds.halfExtents.set(0.0, 0.0, 0.0);
+
+ const tmpAABB: BoundingBox = new BoundingBox();
+ const invWorldTransform: Mat4 = new Mat4();
+
+ const renderComponents = entity.findComponents('render') as RenderComponent[];
+
+ // Compute the bounds of all render components
+ const updatedBounds = renderComponents.reduce((bounds, component) => {
+ const meshInstances = component.meshInstances;
+ meshInstances.forEach((mi) => {
+ invWorldTransform.copy(mi.node.getWorldTransform()).invert();
+ tmpAABB.setFromTransformedAabb(mi.aabb, invWorldTransform);
+ bounds.add(mi.aabb);
+ });
+ return bounds;
+ }, bounds);
+
+ boundsRef.current = updatedBounds;
+ setBounds(updatedBounds);
}, [app, parent, Children.count(children)]);
-
+
+ /* eslint-disable react-hooks/refs -- layout updates intentionally read the cached engine bounds */
const { center, halfExtents } = boundsRef.current;
-
+
// Align based on bounds and alignment flags
const position: [number, number, number] = [
- left ? -center.x + halfExtents.x : right ? -center.x - halfExtents.x : -center.x,
- bottom ? -center.y + halfExtents.y : top ? -center.y - halfExtents.y : -center.y,
- front ? -center.z + halfExtents.z : back ? -center.z - halfExtents.z : -center.z,
+ left ? -center.x + halfExtents.x : right ? -center.x - halfExtents.x : -center.x,
+ bottom ? -center.y + halfExtents.y : top ? -center.y - halfExtents.y : -center.y,
+ front ? -center.z + halfExtents.z : back ? -center.z - halfExtents.z : -center.z
];
-
+ /* eslint-enable react-hooks/refs */
+
return (
-
- {children}
-
+
+ {children}
+
);
-};
\ No newline at end of file
+};
diff --git a/packages/lib/src/components/Anim.tsx b/packages/lib/src/components/Anim.tsx
index 717fdcd2..ababeed1 100644
--- a/packages/lib/src/components/Anim.tsx
+++ b/packages/lib/src/components/Anim.tsx
@@ -1,20 +1,23 @@
-"use client"
+'use client';
-import { FC, useLayoutEffect } from "react";
-import { useComponent, useParent } from "../hooks/index.ts";
-import { AnimComponent, AnimTrack, Entity, Asset } from "playcanvas";
-import { PublicProps, Serializable } from "../utils/types-utils.ts";
-import { validatePropsWithDefaults, createComponentDefinition, getStaticNullApplication } from "../utils/validation.ts";
-import { GlbContainerResource } from "playcanvas/build/playcanvas/src/framework/parsers/glb-container-resource.js";
+import type { AnimComponent, AnimTrack } from 'playcanvas';
+import { Entity, Asset } from 'playcanvas';
+import type { GlbContainerResource } from 'playcanvas/build/playcanvas/src/framework/parsers/glb-container-resource.js';
+import type { FC } from 'react';
+import { useLayoutEffect } from 'react';
+
+import { useComponent, useParent } from '../hooks/index.ts';
+import type { PublicProps, Serializable } from '../utils/types-utils.ts';
+import { validatePropsWithDefaults, createComponentDefinition, getStaticNullApplication } from '../utils/validation.ts';
/**
* The Anim component allows an entity to play animations.
* GLB's and GLTF's often contain animations. When you attach this component to an entity,
* it will automatically animate them. You'll also need a Render component to display the entity.
- *
+ *
* @param {AnimProps} props - The props to pass to the animation component.
* @see https://api.playcanvas.com/engine/classes/AnimComponent.html
- *
+ *
* @example
*
*
@@ -22,51 +25,49 @@ import { GlbContainerResource } from "playcanvas/build/playcanvas/src/framework/
*
*/
export const Anim: FC = (props) => {
-
const { asset, ...safeProps } = validatePropsWithDefaults(props, componentDefinition);
// Create the anim component
- useComponent("anim", safeProps, componentDefinition.schema);
+ useComponent('anim', safeProps, componentDefinition.schema);
// Get the associated Entity
- const entity : Entity = useParent();
+ const entity: Entity = useParent();
// Assign Asset animations to Anim component
useLayoutEffect(() => {
+ const anim: AnimComponent | undefined = entity.anim;
- const anim : AnimComponent | undefined = entity.anim
-
- if(!asset?.resource) return;
+ if (!asset?.resource) return;
const resource = asset.resource as GlbContainerResource;
// Early out if component instantiation fails, or the asset contains no animations
- if(!anim || !resource?.animations || resource.animations.length === 0) return;
-
+ if (!anim || !resource?.animations || resource.animations.length === 0) return;
+
// Filter out non animations, and assign animations to component
resource.animations
- .filter(animation => animation.type === 'animation')
- .forEach(animation => {
- anim.assignAnimation('animation', animation.resource as AnimTrack)
+ .filter((animation) => animation.type === 'animation')
+ .forEach((animation) => {
+ anim.assignAnimation('animation', animation.resource as AnimTrack);
});
-
- }, [asset, asset?.id, asset?.resource, entity.getGuid()])
+ }, [asset, asset?.id, asset?.resource, entity.getGuid()]);
return null;
-}
+};
+// eslint-disable-next-line @typescript-eslint/consistent-type-definitions -- preserve the inherited engine props
interface AnimProps extends Partial>> {
/**
* The asset containing the animations to play. Setting this prop will automatically assign the animations to the component.
* @type {Asset}
*/
- asset : Asset
+ asset: Asset;
}
const componentDefinition = createComponentDefinition(
- "Anim",
- () => new Entity("mock-anim", getStaticNullApplication()).addComponent('anim') as AnimComponent,
+ 'Anim',
+ () => new Entity('mock-anim', getStaticNullApplication()).addComponent('anim') as AnimComponent,
(component) => (component as AnimComponent).system.destroy()
-)
+);
componentDefinition.schema = {
...componentDefinition.schema,
@@ -75,4 +76,4 @@ componentDefinition.schema = {
errorMsg: (value: unknown) => `Invalid value for prop "asset": ${value}. Expected an Asset.`,
default: undefined
}
-}
\ No newline at end of file
+};
diff --git a/packages/lib/src/components/Camera.tsx b/packages/lib/src/components/Camera.tsx
index 997dac75..fbe794ac 100644
--- a/packages/lib/src/components/Camera.tsx
+++ b/packages/lib/src/components/Camera.tsx
@@ -1,38 +1,38 @@
-"use client"
+'use client';
-import { FC } from "react";
-import { useComponent } from "../hooks/index.ts";
-import { CameraComponent, Entity } from "playcanvas";
-import { PublicProps, Serializable } from "../utils/types-utils.ts";
-import { validatePropsPartial, createComponentDefinition, getStaticNullApplication } from "../utils/validation.ts";
+import type { CameraComponent } from 'playcanvas';
+import { Entity } from 'playcanvas';
+import type { FC } from 'react';
+
+import { useComponent } from '../hooks/index.ts';
+import type { PublicProps, Serializable } from '../utils/types-utils.ts';
+import { validatePropsPartial, createComponentDefinition, getStaticNullApplication } from '../utils/validation.ts';
/**
* The Camera component makes an entity behave like a camera and gives you a view into the scene.
* Moving the container entity will move the camera.
- *
+ *
* @param {CameraProps} props - The props to pass to the camera component.
- *
+ *
* @example
*
*
*
*/
export const Camera: FC = (props) => {
-
const safeProps = validatePropsPartial(props, componentDefinition);
- useComponent("camera", safeProps, componentDefinition.schema);
+ useComponent('camera', safeProps, componentDefinition.schema);
return null;
-
-}
+};
type CameraProps = Partial>>;
const componentDefinition = createComponentDefinition(
- "Camera",
- () => new Entity("mock-camera", getStaticNullApplication()).addComponent('camera') as CameraComponent,
+ 'Camera',
+ () => new Entity('mock-camera', getStaticNullApplication()).addComponent('camera') as CameraComponent,
(component) => (component as CameraComponent).system.destroy(),
- { apiName: "CameraComponent" }
-)
+ { apiName: 'CameraComponent' }
+);
-export { componentDefinition as cameraComponentDefinition };
\ No newline at end of file
+export { componentDefinition as cameraComponentDefinition };
diff --git a/packages/lib/src/components/Collision.tsx b/packages/lib/src/components/Collision.tsx
index e3329d19..5f2c2fc4 100644
--- a/packages/lib/src/components/Collision.tsx
+++ b/packages/lib/src/components/Collision.tsx
@@ -1,18 +1,26 @@
-"use client"
+'use client';
-import { FC, useEffect } from "react";
-import { useComponent, useParent } from "../hooks/index.ts";
-import { usePhysics } from "../contexts/physics-context.tsx";
-import { validatePropsPartial, warnOnce, createComponentDefinition, getStaticNullApplication } from "../utils/validation.ts";
-import { CollisionComponent, Entity } from "playcanvas";
-import { PublicProps, Serializable } from "../utils/types-utils.ts";
+import type { CollisionComponent } from 'playcanvas';
+import { Entity } from 'playcanvas';
+import type { FC } from 'react';
+import { useEffect } from 'react';
+
+import { usePhysics } from '../contexts/physics-context.tsx';
+import { useComponent, useParent } from '../hooks/index.ts';
+import type { PublicProps, Serializable } from '../utils/types-utils.ts';
+import {
+ validatePropsPartial,
+ warnOnce,
+ createComponentDefinition,
+ getStaticNullApplication
+} from '../utils/validation.ts';
/**
* The Collision component adds a collider to the entity. This enables the entity to collide with other entities.
* You can manually define the shape of the collider with the `type` prop, or let the component infer the shape from a `Render` component.
* @param {CollisionProps} props - The props to pass to the collision component.
* @see https://api.playcanvas.com/engine/classes/CollisionComponent.html
- *
+ *
* @example
*
*
@@ -20,7 +28,6 @@ import { PublicProps, Serializable } from "../utils/types-utils.ts";
*
*/
export const Collision: FC = (props) => {
-
const entity = useParent();
const { isPhysicsEnabled, isPhysicsLoaded, physicsError } = usePhysics();
@@ -30,28 +37,28 @@ export const Collision: FC = (props) => {
if (!isPhysicsEnabled) {
warnOnce(
'The `` component requires physics to be enabled.\n\n' +
- 'To fix this:\n' +
- '1. Add `usePhysics` prop to your root Application component:\n' +
- ' \n' +
- ' \n' +
- ' \n' +
- ' \n' +
- ' \n\n' +
- '2. Make sure you have the required dependencies installed:\n' +
- ' npm install sync-ammo\n\n' +
- 'For more information, see: https://developer.playcanvas.com/user-manual/playcanvas-react/guide/physics'
+ 'To fix this:\n' +
+ '1. Add `usePhysics` prop to your root Application component:\n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n\n' +
+ '2. Make sure you have the required dependencies installed:\n' +
+ ' npm install sync-ammo\n\n' +
+ 'For more information, see: https://developer.playcanvas.com/user-manual/playcanvas-react/guide/physics'
);
}
if (physicsError) {
warnOnce(
`Physics initialization failed: ${physicsError.message}\n\n` +
- 'To fix this:\n' +
- '1. Install the required dependency:\n' +
- ' npm install sync-ammo\n\n' +
- '2. Make sure your bundler is configured to handle WASM files\n\n' +
- '3. Check that your server is configured to serve .wasm files with the correct MIME type\n\n' +
- 'For more information, see: https://developer.playcanvas.com/user-manual/playcanvas-react/guide/physics#troubleshooting'
+ 'To fix this:\n' +
+ '1. Install the required dependency:\n' +
+ ' npm install sync-ammo\n\n' +
+ '2. Make sure your bundler is configured to handle WASM files\n\n' +
+ '3. Check that your server is configured to serve .wasm files with the correct MIME type\n\n' +
+ 'For more information, see: https://developer.playcanvas.com/user-manual/playcanvas-react/guide/physics#troubleshooting'
);
}
}, [isPhysicsEnabled, physicsError]);
@@ -60,33 +67,35 @@ export const Collision: FC = (props) => {
const type = entity.render && props.type === undefined ? entity.render.type : props.type;
// Always call useComponent - it will handle component lifecycle internally
- useComponent(isPhysicsLoaded ? "collision" : null, { ...safeProps as Serializable, type }, componentDefinition.schema);
+ useComponent(
+ isPhysicsLoaded ? 'collision' : null,
+ { ...(safeProps as Serializable), type },
+ componentDefinition.schema
+ );
return null;
-}
+};
+// eslint-disable-next-line @typescript-eslint/consistent-type-definitions -- preserve the inherited engine props
interface CollisionProps extends Partial>> {
- type?: "box"
- | "capsule"
- | "compound"
- | "cone"
- | "cylinder"
- | "mesh"
- | "sphere"
+ type?: 'box' | 'capsule' | 'compound' | 'cone' | 'cylinder' | 'mesh' | 'sphere';
}
const componentDefinition = createComponentDefinition(
- "Collision",
- () => new Entity("mock-collision", getStaticNullApplication()).addComponent('collision') as CollisionComponent,
+ 'Collision',
+ () => new Entity('mock-collision', getStaticNullApplication()).addComponent('collision') as CollisionComponent,
(component) => (component as CollisionComponent).system.destroy(),
- { apiName: "CollisionComponent" }
-)
+ { apiName: 'CollisionComponent' }
+);
componentDefinition.schema = {
...componentDefinition.schema,
type: {
- validate: (value: unknown) => typeof value === 'string' && ['box', 'capsule', 'compound', 'cone', 'cylinder', 'mesh', 'sphere'].includes(value as string),
- errorMsg: (value: unknown) => `Invalid value for prop "type": ${value}. Expected one of: "box", "capsule", "compound", "cone", "cylinder", "mesh", "sphere".`,
- default: "box"
+ validate: (value: unknown) =>
+ typeof value === 'string' &&
+ ['box', 'capsule', 'compound', 'cone', 'cylinder', 'mesh', 'sphere'].includes(value as string),
+ errorMsg: (value: unknown) =>
+ `Invalid value for prop "type": ${value}. Expected one of: "box", "capsule", "compound", "cone", "cylinder", "mesh", "sphere".`,
+ default: 'box'
}
-}
\ No newline at end of file
+};
diff --git a/packages/lib/src/components/Element.test.tsx b/packages/lib/src/components/Element.test.tsx
index 6a3fe69b..fc124205 100644
--- a/packages/lib/src/components/Element.test.tsx
+++ b/packages/lib/src/components/Element.test.tsx
@@ -1,11 +1,13 @@
-import React from 'react';
import { render, waitFor } from '@testing-library/react';
+import type { Entity as PcEntity } from 'playcanvas';
+import React from 'react';
import { describe, it, expect } from 'vitest';
-import { Entity as PcEntity } from 'playcanvas';
+
+import { Application } from '../Application.tsx';
+import { Entity } from '../Entity.tsx';
+
import { Element } from './Element.tsx';
import { Screen } from './Screen.tsx';
-import { Entity } from '../Entity.tsx';
-import { Application } from '../Application.tsx';
describe('Element', () => {
// Regression: the auto-generated schema used to include the engine's read-only
@@ -14,7 +16,7 @@ describe('Element', () => {
const ref = React.createRef();
render(
-
+
diff --git a/packages/lib/src/components/Element.tsx b/packages/lib/src/components/Element.tsx
index 284953ec..fdecbd68 100644
--- a/packages/lib/src/components/Element.tsx
+++ b/packages/lib/src/components/Element.tsx
@@ -1,10 +1,13 @@
-"use client"
+'use client';
-import { FC } from "react";
-import { useComponent } from "../hooks/index.ts";
-import { Entity, ElementComponent } from "playcanvas";
-import { PublicProps, Serializable } from "../utils/types-utils.ts";
-import { validatePropsWithDefaults, createComponentDefinition, getStaticNullApplication, Schema } from "../utils/validation.ts";
+import type { ElementComponent } from 'playcanvas';
+import { Entity } from 'playcanvas';
+import type { FC } from 'react';
+
+import { useComponent } from '../hooks/index.ts';
+import type { PublicProps, Serializable } from '../utils/types-utils.ts';
+import type { Schema } from '../utils/validation.ts';
+import { validatePropsWithDefaults, createComponentDefinition, getStaticNullApplication } from '../utils/validation.ts';
/**
* The Element component renders 2D UI content — text, an image, or a group — on an entity.
@@ -24,25 +27,24 @@ import { validatePropsWithDefaults, createComponentDefinition, getStaticNullAppl
export const Element: FC = (props) => {
const safeProps = validatePropsWithDefaults(props, componentDefinition);
- useComponent("element", safeProps, componentDefinition.schema);
+ useComponent('element', safeProps, componentDefinition.schema);
return null;
};
-type ElementProps = Partial>>
+type ElementProps = Partial>>;
const componentDefinition = createComponentDefinition(
- "Element",
- () => new Entity("mock-element", getStaticNullApplication()).addComponent("element") as ElementComponent,
+ 'Element',
+ () => new Entity('mock-element', getStaticNullApplication()).addComponent('element') as ElementComponent,
(component) => (component as ElementComponent).system.destroy(),
- { apiName: "ElementComponent" }
+ { apiName: 'ElementComponent' }
);
-
componentDefinition.schema = {
...componentDefinition.schema,
children: {
- validate: (value: unknown) => typeof value === "string",
+ validate: (value: unknown) => typeof value === 'string',
errorMsg: (value: unknown) => `Invalid value for prop "children": ${value}. Expected a string.`,
- default: "Invalid children"
+ default: 'Invalid children'
}
-} as Schema;
\ No newline at end of file
+} as Schema;
diff --git a/packages/lib/src/components/Environment.tsx b/packages/lib/src/components/Environment.tsx
index a94b9695..dcc5ee9e 100644
--- a/packages/lib/src/components/Environment.tsx
+++ b/packages/lib/src/components/Environment.tsx
@@ -1,10 +1,17 @@
-import { EnvLighting, Quat, Scene, Sky, SKYTYPE_DOME, SKYTYPE_INFINITE, Texture } from "playcanvas";
-import { useEffect, useRef } from "react";
-import { useApp } from "../hooks/use-app.tsx";
-import { createComponentDefinition, getStaticNullApplication, Schema, validatePropsWithDefaults, warnOnce } from "../utils/validation.ts";
-import { PublicProps } from "../utils/types-utils.ts";
-import { Asset } from "playcanvas";
-import dedent from "dedent";
+import dedent from 'dedent';
+import type { Scene, Texture } from 'playcanvas';
+import { EnvLighting, Quat, Sky, SKYTYPE_DOME, SKYTYPE_INFINITE, Asset } from 'playcanvas';
+import { useEffect, useRef } from 'react';
+
+import { useApp } from '../hooks/use-app.tsx';
+import type { PublicProps } from '../utils/types-utils.ts';
+import type { Schema } from '../utils/validation.ts';
+import {
+ createComponentDefinition,
+ getStaticNullApplication,
+ validatePropsWithDefaults,
+ warnOnce
+} from '../utils/validation.ts';
const appUUIDs = new Set();
@@ -20,9 +27,8 @@ const appUUIDs = new Set();
* ```
*/
function Environment(props: EnvironmentProps) {
-
const app = useApp();
-
+
// split the sky props and scene props
const { center, scale, rotation, depthWrite, type, showSkybox, ...sceneProps } = props;
const skyProps = { center, scale, rotation, depthWrite, type, showSkybox };
@@ -30,13 +36,13 @@ function Environment(props: EnvironmentProps) {
// Sanitize and validate the props
const safeSceneProps = validatePropsWithDefaults(sceneProps, sceneComponentDefinition);
const safeSkyProps = validatePropsWithDefaults(skyProps, skyComponentDefinition);
-
+ /* eslint-disable react-hooks/immutability, react-hooks/refs -- these effects intentionally mutate the engine scene */
/**
* We want to ensure that the environment is only set once per app instance.
* This is because the environment is a global state and we don't want to
* set it multiple times.
- *
+ *
* If multiple components are used in the same app instance, we will warn the user
* and only the first component will be used.
*/
@@ -53,11 +59,11 @@ function Environment(props: EnvironmentProps) {
Only the first \`\` component will be used.`
);
}
-
+
return () => {
appUUIDs.delete(appUUID);
- }
- })
+ };
+ });
/**
* Sets the skybox of the environment.
@@ -72,13 +78,13 @@ function Environment(props: EnvironmentProps) {
const isCubeMap = Array.isArray(skyBoxAsset.resources) && skyBoxAsset.resources.length === 6;
let skybox: Texture = skyBoxAsset.resource as Texture;
-
+
// If the skybox is not a cube map, try to generate a cube map from it.
if (!isCubeMap) {
skybox = EnvLighting.generateSkyboxCubemap(skyBoxAsset.resource as Texture);
}
-
- app.scene.skybox = skybox;
+
+ app.scene.skybox = skybox;
return () => {
if (app?.scene) {
@@ -87,7 +93,6 @@ function Environment(props: EnvironmentProps) {
};
}, [appHasEnvironment.current, safeSceneProps.skybox?.id]);
-
/**
* Sets the environment lighting.
*/
@@ -95,7 +100,7 @@ function Environment(props: EnvironmentProps) {
// If the app already has an environment, don't override it
if (appHasEnvironment.current) return;
- app.scene.envAtlas = safeSceneProps?.envAtlas?.resource as Texture ?? null;
+ app.scene.envAtlas = (safeSceneProps?.envAtlas?.resource as Texture) ?? null;
return () => {
if (app?.scene) {
@@ -107,28 +112,32 @@ function Environment(props: EnvironmentProps) {
/**
* Sets the remaining environment settings.
*/
- useEffect(() => {
+ useEffect(() => {
if (appHasEnvironment.current) return;
app.scene.exposure = safeSceneProps.exposure ?? 1;
- app.scene.envAtlas = safeSceneProps.envAtlas?.resource as Texture ?? null;
-
+ app.scene.envAtlas = (safeSceneProps.envAtlas?.resource as Texture) ?? null;
+
if (safeSkyProps.rotation) {
- app.scene.skyboxRotation = new Quat().setFromEulerAngles(safeSkyProps.rotation[0], safeSkyProps.rotation[1], safeSkyProps.rotation[2]);
+ app.scene.skyboxRotation = new Quat().setFromEulerAngles(
+ safeSkyProps.rotation[0],
+ safeSkyProps.rotation[1],
+ safeSkyProps.rotation[2]
+ );
}
-
+
if (safeSkyProps.scale) {
app.scene.sky.node.setLocalScale(...safeSkyProps.scale);
}
-
+
if (safeSkyProps.position) {
- app.scene.sky.node.setLocalPosition(...safeSkyProps.position);
+ app.scene.sky.node.setLocalPosition(...safeSkyProps.position);
}
-
+
if (safeSkyProps.center) {
app.scene.sky.center.set(...safeSkyProps.center);
}
-
+
app.scene.sky.type = safeSkyProps.type ?? SKYTYPE_DOME;
app.scene.sky.depthWrite = safeSkyProps.depthWrite ?? true;
@@ -145,21 +154,19 @@ function Environment(props: EnvironmentProps) {
}
return () => {
-
/**
* We have hardcoded the default values for the scene and sky in order to reset them
- *
+ *
* This isn't perfect as any changes the the engine defaults will break this.
* TODO: Find a better way to reset the scene and sky.
*/
-
+
if (app.scene) {
-
app.scene.exposure = 1;
app.scene.skyboxRotation = new Quat().setFromEulerAngles(0, 0, 0);
app.scene.sky.node.setLocalScale(1, 1, 1);
app.scene.sky.node.setLocalPosition(0, 0, 0);
- app.scene.sky.center.set(0, 0.05, 0);
+ app.scene.sky.center.set(0, 0.05, 0);
app.scene.sky.type = SKYTYPE_INFINITE;
app.scene.sky.depthWrite = false;
app.scene.skyboxMip = 0;
@@ -173,10 +180,9 @@ function Environment(props: EnvironmentProps) {
}
}
};
-
}, [
- appHasEnvironment.current,
- safeSceneProps.exposure,
+ appHasEnvironment.current,
+ safeSceneProps.exposure,
safeSkyProps.type,
safeSkyProps.depthWrite,
safeSkyProps.showSkybox,
@@ -186,102 +192,96 @@ function Environment(props: EnvironmentProps) {
safeSceneProps.skyboxHighlightMultiplier,
// compute keys for scale, rotation, and center
- `scale-${safeSkyProps.scale?.join('-')}`,
+ `scale-${safeSkyProps.scale?.join('-')}`,
`rotation-${safeSkyProps.rotation?.join('-')}`,
- `center-${safeSkyProps.center?.join('-')}`,
-
+ `center-${safeSkyProps.center?.join('-')}`
]);
+ /* eslint-enable react-hooks/immutability, react-hooks/refs */
- return null
-};
-
+ return null;
+}
// Types
type SceneProps = Omit>, 'skybox' | 'envAtlas'> & {
/**
* The skybox asset.
* Used to set the skybox of the environment.
- */
- skybox?: Asset | null
- /**
- * The environment lighting.
- * Used to set the lighting of the environment.
- */
- envAtlas?: Asset | null
-
-}
+ */
+ skybox?: Asset | null;
+ /**
+ * The environment lighting.
+ * Used to set the lighting of the environment.
+ */
+ envAtlas?: Asset | null;
+};
type SkyProps = Omit>, 'node' | 'center'> & {
/**
* The scale of the sky.
*/
- scale?: [number, number, number]
+ scale?: [number, number, number];
/**
* The position of the sky.
*/
- position?: [number, number, number]
+ position?: [number, number, number];
/**
* The center of the sky.
*/
- center?: [number, number, number]
+ center?: [number, number, number];
/**
* The rotation of the sky.
*/
- rotation?: [number, number, number]
+ rotation?: [number, number, number];
/**
* Whether to show the skybox.
*/
- showSkybox?: boolean
-}
+ showSkybox?: boolean;
+};
type EnvironmentProps = SkyProps & SceneProps;
// Component Definitions
const skyComponentDefinition = createComponentDefinition(
- "Sky",
+ 'Sky',
() => new Sky(getStaticNullApplication().scene),
- (sky: Sky) => sky.resetSkyMesh(),
+ (sky: Sky) => sky.resetSkyMesh()
);
const sceneComponentDefinition = createComponentDefinition(
- "Scene",
+ 'Scene',
() => getStaticNullApplication().scene
);
skyComponentDefinition.schema = {
...skyComponentDefinition.schema,
scale: {
- validate: (value: unknown) => Array.isArray(value)
- && value.length === 3
- && value.every(v => typeof v === 'number'),
+ validate: (value: unknown) =>
+ Array.isArray(value) && value.length === 3 && value.every((v) => typeof v === 'number'),
errorMsg: (value: unknown) => `Expected an array of 3 numbers, got \`${typeof value}\``,
- default: [100, 100, 100],
+ default: [100, 100, 100]
},
rotation: {
- validate: (value: unknown) => Array.isArray(value)
- && value.length === 3
- && value.every(v => typeof v === 'number'),
+ validate: (value: unknown) =>
+ Array.isArray(value) && value.length === 3 && value.every((v) => typeof v === 'number'),
errorMsg: (value: unknown) => `Expected an array of 3 numbers, got \`${typeof value}\``,
- default: [0, 0, 0],
+ default: [0, 0, 0]
},
position: {
- validate: (value: unknown) => Array.isArray(value)
- && value.length === 3
- && value.every(v => typeof v === 'number'),
+ validate: (value: unknown) =>
+ Array.isArray(value) && value.length === 3 && value.every((v) => typeof v === 'number'),
errorMsg: (value: unknown) => `Expected an array of 3 numbers, got \`${typeof value}\``,
- default: [0, 0, 0],
+ default: [0, 0, 0]
},
center: {
- validate: (value: unknown) => Array.isArray(value)
- && value.length === 3
- && value.every(v => typeof v === 'number'),
+ validate: (value: unknown) =>
+ Array.isArray(value) && value.length === 3 && value.every((v) => typeof v === 'number'),
errorMsg: (value: unknown) => `Expected an array of 3 numbers, got \`${typeof value}\``,
- default: [0, 0.05, 0],
+ default: [0, 0.05, 0]
},
showSkybox: {
- validate: (value: unknown) => typeof value === "boolean",
+ validate: (value: unknown) => typeof value === 'boolean',
errorMsg: (value: unknown) => `Expected a boolean, got \`${typeof value}\``,
- default: true,
+ default: true
}
} as Schema;
@@ -290,13 +290,13 @@ sceneComponentDefinition.schema = {
envAtlas: {
validate: (value: unknown) => value instanceof Asset && value.type === 'texture',
errorMsg: (value: unknown) => `Expected a \`Asset\` instance, got \`${typeof value}\``,
- default: null,
+ default: null
},
skybox: {
validate: (value: unknown) => value instanceof Asset && value.type === 'texture',
errorMsg: (value: unknown) => `Expected a \`Asset\` instance, got \`${typeof value}\``,
- default: null,
- },
+ default: null
+ }
} as Schema;
-export { Environment };
\ No newline at end of file
+export { Environment };
diff --git a/packages/lib/src/components/GSplat.tsx b/packages/lib/src/components/GSplat.tsx
index bbd0ae6c..3b68b861 100644
--- a/packages/lib/src/components/GSplat.tsx
+++ b/packages/lib/src/components/GSplat.tsx
@@ -1,10 +1,12 @@
-"use client"
+'use client';
-import { FC } from "react";
-import { useComponent } from "../hooks/index.ts";
-import { Asset, Entity, GSplatComponent, GSplatInstance, ShaderMaterial } from "playcanvas";
-import { PublicProps } from "../utils/types-utils.ts";
-import { validatePropsWithDefaults, createComponentDefinition, getStaticNullApplication } from "../utils/validation.ts";
+import type { GSplatComponent } from 'playcanvas';
+import { Asset, Entity, GSplatInstance, ShaderMaterial } from 'playcanvas';
+import type { FC } from 'react';
+
+import { useComponent } from '../hooks/index.ts';
+import type { PublicProps } from '../utils/types-utils.ts';
+import { validatePropsWithDefaults, createComponentDefinition, getStaticNullApplication } from '../utils/validation.ts';
/**
* The GSplat component allows an entity to render a Gaussian Splat.
* @param {GSplatProps} props - The props to pass to the GSplat component.
@@ -14,23 +16,21 @@ import { validatePropsWithDefaults, createComponentDefinition, getStaticNullAppl
*
*/
export const GSplat: FC = (props) => {
-
const safeProps = validatePropsWithDefaults(props, componentDefinition);
- useComponent("gsplat", safeProps, componentDefinition.schema);
- return null
-
+ useComponent('gsplat', safeProps, componentDefinition.schema);
+ return null;
};
-type GSplatProps = Partial>
+type GSplatProps = Partial>;
const componentDefinition = createComponentDefinition(
- "GSplat",
- () => new Entity("mock-gsplat", getStaticNullApplication()).addComponent('gsplat') as GSplatComponent,
+ 'GSplat',
+ () => new Entity('mock-gsplat', getStaticNullApplication()).addComponent('gsplat') as GSplatComponent,
(component) => (component as GSplatComponent).system.destroy(),
// `id` is a getter-only property and `lodDistances`/`splatBudget` are removed
// in engine 2.18+, so they must not become settable schema props
- { apiName: "GSplatComponent", exclude: ['id', 'lodDistances', 'splatBudget'] }
-)
+ { apiName: 'GSplatComponent', exclude: ['id', 'lodDistances', 'splatBudget'] }
+);
componentDefinition.schema = {
...componentDefinition.schema,
@@ -89,4 +89,4 @@ componentDefinition.schema = {
instance.instance = value;
}
}
-}
+};
diff --git a/packages/lib/src/components/Gizmo.tsx b/packages/lib/src/components/Gizmo.tsx
index c2ba3796..751e8bd9 100644
--- a/packages/lib/src/components/Gizmo.tsx
+++ b/packages/lib/src/components/Gizmo.tsx
@@ -1,6 +1,9 @@
-import { useApp } from "@playcanvas/react/hooks";
-import { useCallback, useEffect, useMemo, useRef } from "react";
-import { CameraComponent, GraphNode, Gizmo as PcGizmo, RotateGizmo, ScaleGizmo, TransformGizmo, TranslateGizmo } from "playcanvas";
+/* eslint-disable import-x/order -- preserve the existing self-import initialization order */
+import { useApp } from '@playcanvas/react/hooks';
+import { useCallback, useEffect, useMemo, useRef } from 'react';
+import type { CameraComponent, GraphNode } from 'playcanvas';
+import { Gizmo as PcGizmo, RotateGizmo, ScaleGizmo, TransformGizmo, TranslateGizmo } from 'playcanvas';
+/* eslint-enable import-x/order */
/**
* A gizmo component that allows you to manipulate the nodes in the scene.
@@ -17,77 +20,76 @@ import { CameraComponent, GraphNode, Gizmo as PcGizmo, RotateGizmo, ScaleGizmo,
* ```
*/
export function Gizmo({ camera, nodes, mode, onCommit }: Props) {
- const app = useApp();
- const gizmoRef = useRef(null);
- const layer = useMemo(() => PcGizmo.createLayer(app), [app]);
+ const app = useApp();
+ const gizmoRef = useRef(null);
+ const layer = useMemo(() => PcGizmo.createLayer(app), [app]);
- const onGizmoCommit = useCallback(() => {
- if (!gizmoRef.current) return;
+ const onGizmoCommit = useCallback(() => {
+ if (!gizmoRef.current) return;
- const updated = nodes.map((node) => ({
- id: node.name,
- position: node.getPosition().toArray(),
- rotation: node.getEulerAngles().toArray(),
- scale: node.getLocalScale().toArray()
- })) as EntityProps[];
+ const updated = nodes.map((node) => ({
+ id: node.name,
+ position: node.getPosition().toArray(),
+ rotation: node.getEulerAngles().toArray(),
+ scale: node.getLocalScale().toArray()
+ })) as EntityProps[];
- onCommit(updated);
+ onCommit(updated);
+ }, [nodes, onCommit]);
- }, [nodes, onCommit]);
+ useEffect(() => {
+ switch (mode) {
+ case 'rotate':
+ gizmoRef.current = new RotateGizmo(camera, layer);
+ break;
+ case 'scale':
+ gizmoRef.current = new ScaleGizmo(camera, layer);
+ break;
+ case 'translate':
+ gizmoRef.current = new TranslateGizmo(camera, layer);
+ break;
+ }
- useEffect(() => {
- switch (mode) {
- case "rotate":
- gizmoRef.current = new RotateGizmo(camera, layer);
- break;
- case "scale":
- gizmoRef.current = new ScaleGizmo(camera, layer);
- break;
- case "translate":
- gizmoRef.current = new TranslateGizmo(camera, layer);
- break;
- }
+ gizmoRef.current.on(TransformGizmo.EVENT_TRANSFORMEND, onGizmoCommit);
- gizmoRef.current.on(TransformGizmo.EVENT_TRANSFORMEND, onGizmoCommit);
+ return () => {
+ gizmoRef.current?.destroy?.();
+ };
+ }, [camera, layer, mode]);
- return () => {
- gizmoRef.current?.destroy?.();
- };
- }, [camera, layer, mode]);
+ useEffect(() => {
+ if (!gizmoRef.current) return;
+ gizmoRef.current.attach(nodes);
+ }, [nodes]);
- useEffect(() => {
- if (!gizmoRef.current) return;
- gizmoRef.current.attach(nodes);
- }, [nodes]);
-
- return null;
+ return null;
}
-type Mode = "rotate" | "scale" | "translate";
+type Mode = 'rotate' | 'scale' | 'translate';
type EntityProps = {
- id: string;
- name: string;
- position: [number, number, number];
- rotation: [number, number, number];
- scale: [number, number, number];
+ id: string;
+ name: string;
+ position: [number, number, number];
+ rotation: [number, number, number];
+ scale: [number, number, number];
};
type Props = {
- /**
- * The camera component to use for the gizmo.
- */
- camera: CameraComponent;
- /**
- * The nodes to attach the gizmo to.
- */
- nodes: GraphNode[];
- /**
- * The mode of the gizmo.
- */
- mode: Mode;
- /**
- * The function to call when the gizmo is committed.
- */
- onCommit: (props: EntityProps[]) => void;
-};
\ No newline at end of file
+ /**
+ * The camera component to use for the gizmo.
+ */
+ camera: CameraComponent;
+ /**
+ * The nodes to attach the gizmo to.
+ */
+ nodes: GraphNode[];
+ /**
+ * The mode of the gizmo.
+ */
+ mode: Mode;
+ /**
+ * The function to call when the gizmo is committed.
+ */
+ onCommit: (props: EntityProps[]) => void;
+};
diff --git a/packages/lib/src/components/Light.tsx b/packages/lib/src/components/Light.tsx
index 5eb5fef7..636a3be1 100644
--- a/packages/lib/src/components/Light.tsx
+++ b/packages/lib/src/components/Light.tsx
@@ -1,52 +1,56 @@
-import { FC } from "react";
-import { useComponent } from "../hooks/index.ts";
-import { Entity, LightComponent } from "playcanvas";
-import { PublicProps, Serializable } from "../utils/types-utils.ts";
-import { validatePropsWithDefaults, createComponentDefinition, getStaticNullApplication, Schema } from "../utils/validation.ts";
+import type { LightComponent } from 'playcanvas';
+import { Entity } from 'playcanvas';
+import type { FC } from 'react';
+
+import { useComponent } from '../hooks/index.ts';
+import type { PublicProps, Serializable } from '../utils/types-utils.ts';
+import type { Schema } from '../utils/validation.ts';
+import { validatePropsWithDefaults, createComponentDefinition, getStaticNullApplication } from '../utils/validation.ts';
/**
* The Light component adds a light source to the entity. A light can be a directional, omni, or spot light.
* Lights can be moved and orientated with the `position` and `rotation` props of its entity.
- *
+ *
* @param {LightProps} props - The props to pass to the light component.
* @see https://api.playcanvas.com/engine/classes/LightComponent.html
- *
+ *
* @example
*
*
*
*/
export const Light: FC = (props) => {
-
const safeProps = validatePropsWithDefaults(props, componentDefinition);
- useComponent("light", safeProps, componentDefinition.schema);
- return null
-
-}
+ useComponent('light', safeProps, componentDefinition.schema);
+ return null;
+};
+// eslint-disable-next-line @typescript-eslint/consistent-type-definitions -- preserve the inherited engine props
interface LightProps extends Partial>> {
/**
* The type of the light.
* @default "directional"
*/
- type: "directional" | "omni" | "spot";
+ type: 'directional' | 'omni' | 'spot';
}
const componentDefinition = createComponentDefinition(
- "Light",
+ 'Light',
() => new Entity('mock-light', getStaticNullApplication()).addComponent('light') as LightComponent,
(component) => (component as LightComponent).system.destroy(),
- { apiName: "LightComponent" }
-)
+ { apiName: 'LightComponent' }
+);
componentDefinition.schema = {
...componentDefinition.schema,
type: {
- validate: (value: unknown) => typeof value === 'string' && ['directional', 'omni', 'spot'].includes(value as string),
- errorMsg: (value: unknown) => `Invalid value for prop "type": ${value}. Expected one of: "directional", "omni", "spot".`,
- default: "directional"
+ validate: (value: unknown) =>
+ typeof value === 'string' && ['directional', 'omni', 'spot'].includes(value as string),
+ errorMsg: (value: unknown) =>
+ `Invalid value for prop "type": ${value}. Expected one of: "directional", "omni", "spot".`,
+ default: 'directional'
}
-} as Schema
+} as Schema;
-export { componentDefinition as lightComponentDefinition };
\ No newline at end of file
+export { componentDefinition as lightComponentDefinition };
diff --git a/packages/lib/src/components/Render.tsx b/packages/lib/src/components/Render.tsx
index 4b72a6f7..4d93172f 100644
--- a/packages/lib/src/components/Render.tsx
+++ b/packages/lib/src/components/Render.tsx
@@ -1,32 +1,34 @@
-"use client"
+'use client';
-import { FC } from "react";
-import { useComponent } from "../hooks/index.ts";
-import { Container } from "../Container.tsx";
-import { Asset, Entity, type RenderComponent as PcRenderComponent } from "playcanvas";
-import { PublicProps, Serializable } from "../utils/types-utils.ts";
-import { getStaticNullApplication, validatePropsPartial, Schema } from "../utils/validation.ts";
-import { createComponentDefinition } from "../utils/validation.ts";
+import { Asset, Entity } from 'playcanvas';
+import type { RenderComponent as PcRenderComponent } from 'playcanvas';
+import type { FC } from 'react';
+
+import { Container } from '../Container.tsx';
+import { useComponent } from '../hooks/index.ts';
+import type { PublicProps, Serializable } from '../utils/types-utils.ts';
+import type { Schema } from '../utils/validation.ts';
+import { getStaticNullApplication, validatePropsPartial, createComponentDefinition } from '../utils/validation.ts';
const RenderComponent: FC = (props) => {
// console.log('RenderComponent', props.material.diffuse);
- useComponent("render", props, componentDefinition.schema as Schema);
+ useComponent('render', props, componentDefinition.schema as Schema);
return null;
-}
+};
/**
* A Render component allows an entity to render a 3D model. You can specify the type of model to render with the `type` prop,
* which can be a primitive shape, or a model asset.
- *
+ *
* @param {RenderProps} props - The props to pass to the render component.
* @see https://api.playcanvas.com/engine/classes/RenderComponent.html
- *
+ *
* @example
* const { data: asset } = useAsset('./statue.glb')
*
* |