├── .npmrc ├── pnpm-workspace.yaml ├── packages ├── lib │ ├── src │ │ ├── utils │ │ │ ├── index.ts │ │ │ ├── env.ts │ │ │ ├── synthetic-event.ts │ │ │ ├── types-utils.ts │ │ │ ├── compare.tsx │ │ │ ├── create-graphics-device.ts │ │ │ └── fetch-asset.ts │ │ ├── scripts │ │ │ ├── index.ts │ │ │ ├── auto-rotator │ │ │ │ └── auto-rotator.tsx │ │ │ └── orbit-controls │ │ │ │ └── index.tsx │ │ ├── gltf │ │ │ ├── context.tsx │ │ │ ├── index.ts │ │ │ ├── hooks │ │ │ │ ├── use-gltf.tsx │ │ │ │ └── use-entity.tsx │ │ │ ├── components │ │ │ │ ├── ModifyLight.tsx │ │ │ │ ├── ModifyRender.tsx │ │ │ │ └── ModifyCamera.tsx │ │ │ ├── utils │ │ │ │ └── schema-registry.ts │ │ │ └── types.ts │ │ ├── contexts │ │ │ ├── pointer-events-context.tsx │ │ │ └── physics-context.tsx │ │ ├── index.ts │ │ ├── hooks │ │ │ ├── index.ts │ │ │ ├── use-app.tsx │ │ │ ├── use-parent.tsx │ │ │ ├── use-component.tsx │ │ │ ├── use-material.tsx │ │ │ ├── use-script.tsx │ │ │ ├── use-app-event.ts │ │ │ └── use-app-event.test.tsx │ │ ├── components │ │ │ ├── index.ts │ │ │ ├── Camera.tsx │ │ │ ├── Screen.test.tsx │ │ │ ├── GSplat.tsx │ │ │ ├── Sprite.tsx │ │ │ ├── Element.tsx │ │ │ ├── Light.tsx │ │ │ ├── Script.tsx │ │ │ ├── Align.tsx │ │ │ ├── Gizmo.tsx │ │ │ ├── Screen.tsx │ │ │ ├── Anim.tsx │ │ │ ├── Render.tsx │ │ │ ├── RigidBody.tsx │ │ │ └── Collision.tsx │ │ ├── Container.tsx │ │ ├── types │ │ │ └── graphics-device-options.ts │ │ ├── Application.test.tsx │ │ ├── Container.test.tsx │ │ └── Entity.test.tsx │ ├── test │ │ ├── matchers.ts │ │ ├── constants.ts │ │ ├── README.md │ │ ├── setup.ts │ │ └── utils │ │ │ ├── mock.ts │ │ │ └── gltf-asset-mock.ts │ ├── vitest.config.ts │ ├── eslint.config.mjs │ ├── tsconfig.json │ ├── .playcanvas-react.mdc │ ├── CHANGELOG.md │ └── package.json └── blocks │ ├── src │ ├── index.ts │ ├── lib │ │ └── utils.ts │ ├── splat-viewer │ │ ├── help-button.tsx │ │ ├── index.ts │ │ ├── hooks │ │ │ ├── use-subscribe.ts │ │ │ └── use-render-on-camera-change.tsx │ │ ├── camera-mode-toggle.tsx │ │ ├── controls.tsx │ │ ├── download-button.tsx │ │ ├── full-screen-button.tsx │ │ ├── progress-indicator.tsx │ │ ├── timeline.tsx │ │ └── utils │ │ │ ├── pose.ts │ │ │ ├── effects.tsx │ │ │ ├── math.ts │ │ │ └── spline.ts │ ├── components │ │ └── ui │ │ │ ├── separator.tsx │ │ │ ├── switch.tsx │ │ │ ├── toggle.tsx │ │ │ ├── badge.tsx │ │ │ ├── tooltip.tsx │ │ │ ├── tabs.tsx │ │ │ ├── toggle-group.tsx │ │ │ ├── slider.tsx │ │ │ ├── button.tsx │ │ │ ├── card.tsx │ │ │ ├── table.tsx │ │ │ ├── dialog.tsx │ │ │ └── drawer.tsx │ └── .splat-viewer.mdc │ ├── eslint.config.mjs │ ├── tsconfig.json │ ├── README.md │ ├── package.json │ └── CHANGELOG.md ├── .changeset └── config.json ├── vitest.config.ts ├── .github └── workflows │ ├── lint.yml │ ├── test.yml │ ├── build.yml │ ├── publish.yml │ ├── renovate-changeset.yml │ └── changesets.yml ├── tsconfig.json ├── renovate.json ├── eslint.config.mjs ├── .gitignore ├── LICENSE └── package.json /.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org/ -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - packages/* 3 | 4 | linkWorkspacePackages: true 5 | preferWorkspacePackages: true 6 | -------------------------------------------------------------------------------- /packages/lib/src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export { fetchAsset } from './fetch-asset.ts' 2 | export type { FetchAssetOptions } from './fetch-asset.ts' -------------------------------------------------------------------------------- /packages/lib/src/scripts/index.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | export { OrbitControls } from './orbit-controls/index.tsx'; 4 | export { AutoRotator } from './auto-rotator/auto-rotator.tsx'; 5 | -------------------------------------------------------------------------------- /packages/lib/src/utils/env.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Browser safe environment var. 3 | */ 4 | export const env = typeof process !== 'undefined' && process?.env?.NODE_ENV 5 | ? process.env.NODE_ENV 6 | : 'production'; -------------------------------------------------------------------------------- /packages/blocks/src/index.ts: -------------------------------------------------------------------------------- 1 | // Splat Viewer 2 | import * as Viewer from "./splat-viewer/index.ts" 3 | import { useAssetViewer } from "./splat-viewer/splat-viewer-context.ts"; 4 | export { Viewer, useAssetViewer }; 5 | -------------------------------------------------------------------------------- /packages/blocks/src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "restricted", 8 | "baseBranch": "main", 9 | "updateInternalDependencies": "patch" 10 | } 11 | -------------------------------------------------------------------------------- /packages/lib/src/gltf/context.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { createContext } from 'react'; 4 | import { GltfContextValue } from './types.ts'; 5 | 6 | /** 7 | * Context for Gltf 8 | * Provides access to the hierarchy cache and rule registration 9 | */ 10 | export const GltfContext = createContext(null); 11 | 12 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | import react from '@vitejs/plugin-react'; 3 | 4 | export default defineConfig({ 5 | plugins: [react()], 6 | test: { 7 | environment: 'jsdom', 8 | globals: true, 9 | setupFiles: ['./src/test/setup.ts'], 10 | include: ['**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], 11 | }, 12 | }); -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | branches: [main] 6 | 7 | jobs: 8 | lint: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v6 13 | 14 | - name: Setup pnpm 15 | uses: pnpm/action-setup@v4 16 | 17 | - name: Install Dependencies 18 | run: pnpm install 19 | 20 | - name: Lint 21 | run: pnpm run lint -------------------------------------------------------------------------------- /packages/lib/src/contexts/pointer-events-context.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext } from "react"; 2 | 3 | export const PointerEventsContext = createContext | null>(null); 4 | 5 | export const usePointerEventsContext = () => { 6 | const context = useContext(PointerEventsContext); 7 | if (context === null) { 8 | throw new Error('usePointerEventsContext must be used within a PointerEventsContext.Provider'); 9 | } 10 | return context; 11 | }; -------------------------------------------------------------------------------- /packages/blocks/src/splat-viewer/help-button.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { Button } from "@components/ui/button" 4 | import { useAssetViewer } from "./splat-viewer-context.tsx" 5 | 6 | function HelpButton() { 7 | 8 | const { setOverlay } = useAssetViewer() 9 | 10 | return ( 11 | 14 | ) 15 | } 16 | 17 | export { HelpButton }; -------------------------------------------------------------------------------- /packages/lib/test/matchers.ts: -------------------------------------------------------------------------------- 1 | import { vi } from 'vitest'; 2 | 3 | export function toHaveBeenCalledWithEvent(received: ReturnType, eventName: string, ...args: unknown[]) { 4 | const calls = received.mock.calls; 5 | const pass = calls.some(call => call[0] === eventName && call.slice(1).every((arg, i) => arg === args[i])); 6 | return { 7 | pass, 8 | message: () => `expected ${received.getMockName()} to have been called with event "${eventName}" and args ${JSON.stringify(args)}` 9 | }; 10 | } -------------------------------------------------------------------------------- /packages/lib/src/gltf/index.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | // Components 4 | export { Gltf } from './components/Gltf.tsx'; 5 | export type { GltfProps } from './components/Gltf.tsx'; 6 | export { Modify } from './components/Modify.tsx'; 7 | 8 | // Hooks 9 | export { useEntity } from './hooks/use-entity.ts'; 10 | 11 | // Types 12 | export type { 13 | Rule, 14 | Action, 15 | MergedRule, 16 | ModifyNodeProps, 17 | ModifyLightProps, 18 | ModifyRenderProps, 19 | ModifyCameraProps 20 | } from './types'; 21 | 22 | export type { PathPredicate, EntityMetadata } from './utils/path-matcher.ts'; 23 | 24 | -------------------------------------------------------------------------------- /packages/lib/src/index.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | export { Application, ApplicationWithoutCanvas } from './Application.tsx'; 4 | export { Container } from './Container.tsx'; 5 | export { Entity } from './Entity.tsx'; 6 | 7 | // GLTF Scene Modification API 8 | export { 9 | Gltf, 10 | Modify, 11 | useEntity 12 | } from './gltf/index.ts'; 13 | 14 | export type { 15 | GltfProps, 16 | Rule, 17 | Action, 18 | MergedRule, 19 | ModifyNodeProps, 20 | ModifyLightProps, 21 | ModifyRenderProps, 22 | ModifyCameraProps, 23 | PathPredicate, 24 | EntityMetadata 25 | } from './gltf/index.ts'; -------------------------------------------------------------------------------- /packages/lib/src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | export { useComponent } from './use-component.tsx'; 4 | export { useScript } from './use-script.tsx'; 5 | export { useApp, AppContext } from './use-app.tsx'; 6 | export { useParent, ParentContext } from './use-parent.tsx'; 7 | export { useMaterial } from './use-material.tsx' 8 | export { useAsset, useSplat, useTexture, useEnvAtlas, useModel, useFont } from './use-asset.ts'; 9 | export { useFrame, useAppEvent } from './use-app-event.ts'; 10 | export type { AssetResult } from './use-asset.ts'; 11 | export { usePhysics, type PhysicsContextType } from '../contexts/physics-context.tsx'; -------------------------------------------------------------------------------- /packages/lib/src/components/index.ts: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | export { Camera } from './Camera.tsx' 4 | export { GSplat } from './GSplat.tsx' 5 | export { Light } from './Light.tsx' 6 | export { Render } from './Render.tsx' 7 | export { Script } from './Script.tsx' 8 | export { Sprite } from './Sprite.tsx' 9 | export { Align } from './Align.tsx' 10 | export { Anim } from './Anim.tsx' 11 | export { RigidBody } from './RigidBody.tsx' 12 | export { Collision } from './Collision.tsx' 13 | export { Screen } from './Screen.tsx' 14 | export { Element } from './Element.tsx' 15 | export { Gizmo } from './Gizmo.tsx' 16 | export { Environment } from './Environment.tsx' 17 | -------------------------------------------------------------------------------- /packages/lib/src/hooks/use-app.tsx: -------------------------------------------------------------------------------- 1 | import { Application } from "playcanvas"; 2 | import { useContext, createContext } from "react"; 3 | export const AppContext = createContext(null); 4 | 5 | 6 | /** 7 | * This hook is used to get the application instance. 8 | * @returns {Application} app - The application instance. 9 | * 10 | * @example 11 | * const app = useApp(); 12 | */ 13 | export const useApp = () : Application => { 14 | const appContext = useContext(AppContext); 15 | if (!appContext) { 16 | throw new Error("`useApp` must be used within an Application component"); 17 | } 18 | return appContext; 19 | }; -------------------------------------------------------------------------------- /packages/blocks/src/splat-viewer/index.ts: -------------------------------------------------------------------------------- 1 | import { FullScreenButton } from "./full-screen-button.tsx" 2 | import { DownloadButton } from "./download-button.tsx" 3 | import { MenuButton } from "./menu-button.tsx" 4 | import { Controls } from "./controls.tsx" 5 | import { CameraModeToggle } from "./camera-mode-toggle.tsx" 6 | import { HelpButton } from "./help-button.tsx" 7 | import { SplatViewer } from "./splat-viewer.tsx" 8 | import { Progress } from "./progress-indicator.tsx" 9 | 10 | export { 11 | FullScreenButton, 12 | DownloadButton, 13 | MenuButton, 14 | Controls, 15 | CameraModeToggle, 16 | HelpButton, 17 | SplatViewer as Splat, 18 | Progress, 19 | }; -------------------------------------------------------------------------------- /packages/lib/src/hooks/use-parent.tsx: -------------------------------------------------------------------------------- 1 | import { Entity } from "playcanvas"; 2 | import { useContext, createContext } from "react"; 3 | export const ParentContext = createContext(undefined); 4 | 5 | /** 6 | * This hook is returns parent entity. 7 | * @returns {Entity} parent - The parent entity. 8 | * 9 | * @example 10 | * const parent = useParent(); 11 | */ 12 | export const useParent = (): Entity => { 13 | const context = useContext(ParentContext); 14 | if (context === undefined) { 15 | throw new Error('`useParent` must be used within an App or Entity via a ParentContext.Provider'); 16 | } 17 | return context; 18 | }; 19 | -------------------------------------------------------------------------------- /packages/lib/test/constants.ts: -------------------------------------------------------------------------------- 1 | export const TEST_ENTITY_PROPS = { 2 | name: 'TestEntity', 3 | position: [1, 2, 3] as [number, number, number], 4 | scale: [2, 2, 2] as [number, number, number], 5 | rotation: [45, 0, 0] as [number, number, number] 6 | }; 7 | 8 | export const TEST_APPLICATION_PROPS = { 9 | canvas: document.createElement('canvas'), 10 | graphicsDeviceOptions: { 11 | alpha: true, 12 | depth: true, 13 | antialias: true, 14 | preferWebGl2: true, 15 | powerPreference: 'high-performance' as const 16 | } 17 | }; 18 | 19 | export const TEST_CANVAS_DIMENSIONS = { 20 | style: { 21 | width: 800, 22 | height: 600 23 | } 24 | }; -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | pull_request: 5 | branches: [ main ] 6 | 7 | jobs: 8 | test: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v6 13 | 14 | - name: Setup pnpm 15 | uses: pnpm/action-setup@v4 16 | 17 | - name: Install dependencies 18 | run: pnpm install 19 | 20 | - name: Run tests 21 | run: pnpm test 22 | 23 | - name: Upload test results 24 | if: always() 25 | uses: actions/upload-artifact@v5 26 | with: 27 | name: test-results 28 | path: | 29 | packages/lib/coverage 30 | packages/lib/test-results.xml -------------------------------------------------------------------------------- /packages/lib/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | import react from '@vitejs/plugin-react'; 3 | 4 | export default defineConfig({ 5 | plugins: [react()], 6 | test: { 7 | environment: 'jsdom', 8 | globals: true, 9 | setupFiles: ['./test/setup.ts'], 10 | include: ['src/**/*.test.{ts,tsx}'], 11 | coverage: { 12 | provider: 'v8', 13 | reporter: ['text', 'json', 'html'], 14 | exclude: [ 15 | 'test/**', 16 | 'src/**/*.test.tsx', 17 | 'src/**/*.d.ts' 18 | ], 19 | thresholds: { 20 | lines: 80, 21 | functions: 80, 22 | branches: 80, 23 | statements: 80 24 | } 25 | } 26 | , 27 | } 28 | }); -------------------------------------------------------------------------------- /packages/blocks/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import baseConfig from '../../eslint.config.mjs'; 2 | import importPlugin from 'eslint-plugin-import'; 3 | 4 | /** @type {import('eslint').Linter.Config[]} */ 5 | export default [ 6 | ...baseConfig, 7 | { 8 | plugins: { 9 | 'import': importPlugin, 10 | }, 11 | settings: { 12 | 'import/resolver': { 13 | node: { 14 | extensions: ['.js', '.ts', '.json'] 15 | } 16 | } 17 | }, 18 | rules: { 19 | 'import/extensions': ['error', 'ignorePackages', { 20 | js: 'always', 21 | ts: 'always', 22 | }] 23 | } 24 | }, 25 | { 26 | ignores: [ 27 | "dist/", 28 | "node_modules/", 29 | ], 30 | }, 31 | ]; -------------------------------------------------------------------------------- /packages/lib/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import baseConfig from '../../eslint.config.mjs'; 2 | import importPlugin from 'eslint-plugin-import'; 3 | 4 | /** @type {import('eslint').Linter.Config[]} */ 5 | export default [ 6 | ...baseConfig, 7 | { 8 | plugins: { 9 | 'import': importPlugin, 10 | }, 11 | settings: { 12 | 'import/resolver': { 13 | node: { 14 | extensions: ['.js', '.ts', '.json'] 15 | } 16 | } 17 | }, 18 | rules: { 19 | 'import/extensions': ['error', 'ignorePackages', { 20 | js: 'always', 21 | ts: 'always', 22 | tsx: 'always', 23 | }] 24 | } 25 | }, 26 | { 27 | ignores: [ 28 | "dist/", 29 | "node_modules/", 30 | ], 31 | }, 32 | ]; -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "ESNext", 5 | "strict": true, 6 | "sourceMap": true, 7 | "inlineSources": true, 8 | "esModuleInterop": true, 9 | "skipLibCheck": true, 10 | "jsx": "react-jsx", 11 | "moduleResolution": "bundler", 12 | "baseUrl": ".", 13 | "resolveJsonModule": true, 14 | "rewriteRelativeImportExtensions": true 15 | }, 16 | "include": [ 17 | "packages/**/*.ts", 18 | "packages/**/*.tsx", 19 | "packages/**/*.js" 20 | ], 21 | "exclude": [ 22 | "node_modules", 23 | "dist", 24 | "**/*.test.ts", 25 | "**/*.test.tsx", 26 | "**/*.test.js", 27 | "**/*.test.jsx" 28 | ] 29 | } -------------------------------------------------------------------------------- /packages/lib/src/gltf/hooks/use-gltf.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useContext } from 'react'; 4 | import { GltfContext } from '../context.tsx'; 5 | import { GltfContextValue } from '../types.ts'; 6 | 7 | /** 8 | * Hook to access the Gltf context 9 | * Provides access to the hierarchy cache and utilities 10 | * 11 | * @returns The Gltf context value 12 | * @throws Error if used outside of a Gltf component 13 | * 14 | * @example 15 | * ```tsx 16 | * const { hierarchyCache, rootEntity } = useGltf(); 17 | * ``` 18 | */ 19 | export function useGltf(): GltfContextValue { 20 | const context = useContext(GltfContext); 21 | 22 | if (!context) { 23 | throw new Error('useGltf must be used within a component'); 24 | } 25 | 26 | return context; 27 | } 28 | 29 | -------------------------------------------------------------------------------- /packages/lib/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "sourceMap": true, 5 | "inlineSources": true, 6 | "allowSyntheticDefaultImports": true, 7 | "esModuleInterop": true, 8 | "allowJs": true, 9 | "declaration": true, 10 | "declarationDir": "./dist", 11 | "forceConsistentCasingInFileNames": true, 12 | "removeComments": false, 13 | "noUnusedLocals": true, 14 | "noUnusedParameters": true, 15 | "noImplicitReturns": true, 16 | "noFallthroughCasesInSwitch": true, 17 | "outDir": "./dist", 18 | "rootDir": "./src", 19 | "skipLibCheck": true 20 | }, 21 | "include": ["src"], 22 | "exclude": ["src/**/*.test.tsx", "src/**/*.test.ts", "src/**/*.test.jsx", "src/**/*.test.js"] 23 | } -------------------------------------------------------------------------------- /packages/lib/src/gltf/components/ModifyLight.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React from 'react'; 4 | import { ModifyLightProps } from '../types.ts'; 5 | 6 | /** 7 | * Modify.Light component for modifying existing light components on matched entities 8 | * Must be a child of 9 | * 10 | * @example 11 | * ```tsx 12 | * // Remove a light component 13 | * 14 | * 15 | * // Modify light properties 16 | * 17 | * 18 | * // Functional update 19 | * val * 2} /> 20 | * ``` 21 | */ 22 | export const ModifyLight: React.FC = () => { 23 | // This component only exists to be processed by ModifyNode 24 | // It should never actually render 25 | return null; 26 | }; 27 | 28 | ModifyLight.displayName = 'ModifyLight'; 29 | 30 | -------------------------------------------------------------------------------- /packages/blocks/src/components/ui/separator.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as SeparatorPrimitive from "@radix-ui/react-separator" 5 | 6 | import { cn } from "@lib/utils" 7 | 8 | function Separator({ 9 | className, 10 | orientation = "horizontal", 11 | decorative = true, 12 | ...props 13 | }: React.ComponentProps) { 14 | return ( 15 | 25 | ) 26 | } 27 | 28 | export { Separator } 29 | -------------------------------------------------------------------------------- /packages/lib/src/gltf/components/ModifyRender.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React from 'react'; 4 | import { ModifyRenderProps } from '../types.ts'; 5 | 6 | /** 7 | * Modify.Render component for modifying existing render components on matched entities 8 | * Must be a child of 9 | * 10 | * @example 11 | * ```tsx 12 | * // Remove a render component 13 | * 14 | * 15 | * // Modify render properties 16 | * 17 | * 18 | * // Functional update 19 | * !val} /> 20 | * ``` 21 | */ 22 | export const ModifyRender: React.FC = () => { 23 | // This component only exists to be processed by ModifyNode 24 | // It should never actually render 25 | return null; 26 | }; 27 | 28 | ModifyRender.displayName = 'ModifyRender'; 29 | 30 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["config:recommended"], 4 | "packageRules": [ 5 | { 6 | "matchManagers": ["npm"], 7 | "groupName": "all npm dependencies", 8 | "schedule": ["on monday at 10:00am"] 9 | }, 10 | { 11 | "matchDepTypes": ["devDependencies"], 12 | "rangeStrategy": "pin" 13 | }, 14 | { 15 | "matchDepTypes": ["dependencies"], 16 | "rangeStrategy": "widen" 17 | }, 18 | { 19 | "matchPackageNames": ["playcanvas"], 20 | "groupName": "playcanvas", 21 | "prCreation": "immediate", 22 | "minimumReleaseAge": "0", 23 | "labels": ["playcanvas", "dependencies"], 24 | "assignees": ["marklundin"], 25 | "semanticCommitType": "feat", 26 | "semanticCommitScope": "playcanvas" 27 | } 28 | ] 29 | } -------------------------------------------------------------------------------- /packages/lib/src/gltf/components/ModifyCamera.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React from 'react'; 4 | import { ModifyCameraProps } from '../types.ts'; 5 | 6 | /** 7 | * Modify.Camera component for modifying existing camera components on matched entities 8 | * Must be a child of 9 | * 10 | * @example 11 | * ```tsx 12 | * // Remove a camera component 13 | * 14 | * 15 | * // Modify camera properties 16 | * 17 | * 18 | * // Functional update 19 | * val + 10} /> 20 | * ``` 21 | */ 22 | export const ModifyCamera: React.FC = () => { 23 | // This component only exists to be processed by ModifyNode 24 | // It should never actually render 25 | return null; 26 | }; 27 | 28 | ModifyCamera.displayName = 'ModifyCamera'; 29 | 30 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v6 18 | 19 | - name: Setup pnpm 20 | uses: pnpm/action-setup@v4 21 | 22 | - name: Install dependencies 23 | run: pnpm install 24 | 25 | - name: Build library 26 | run: pnpm run build:lib 27 | 28 | - name: Build blocks 29 | run: pnpm run build:blocks 30 | 31 | - name: Build docs 32 | run: pnpm run build:docs 33 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: [main] 9 | 10 | jobs: 11 | publish: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Cancel Previous Runs 16 | uses: styfle/cancel-workflow-action@0.12.1 17 | with: 18 | access_token: ${{ github.token }} 19 | 20 | - name: Checkout Main 21 | uses: actions/checkout@v6 22 | 23 | - name: Setup pnpm 24 | uses: pnpm/action-setup@v4 25 | 26 | - name: Install Dependencies 27 | run: pnpm install 28 | 29 | - name: Build library 30 | run: pnpm run build:lib 31 | 32 | - name: Build blocks 33 | run: pnpm run build:blocks 34 | 35 | - name: Continuous Release 36 | run: pnpm dlx pkg-pr-new publish --compact './packages/lib' './packages/blocks' -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import globals from "globals"; 2 | import pluginJs from "@eslint/js"; 3 | import tseslint from "typescript-eslint"; 4 | import pluginReact from "eslint-plugin-react"; 5 | import reactCompiler from 'eslint-plugin-react-compiler' 6 | 7 | /** @type {import('eslint').Linter.Config[]} */ 8 | export default [ 9 | {files: ["**/*.{js,mjs,cjs,ts,jsx,tsx}"]}, 10 | {languageOptions: { globals: {...globals.browser, ...globals.node} }}, 11 | pluginJs.configs.recommended, 12 | ...tseslint.configs.recommended, 13 | pluginReact.configs.flat.recommended, 14 | { 15 | plugins: { 16 | 'react-compiler': reactCompiler, 17 | }, 18 | settings: { 19 | react: { 20 | version: "detect", // Automatically detect React version 21 | } 22 | }, 23 | rules: { 24 | 'react/prop-types': 'off', 25 | "react/jsx-uses-react": "off", 26 | "react/react-in-jsx-scope": "off", 27 | "react-compiler/react-compiler": "warn" 28 | }, 29 | } 30 | ]; -------------------------------------------------------------------------------- /packages/blocks/src/splat-viewer/hooks/use-subscribe.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useRef } from 'react'; 2 | 3 | export type SubscribeFn = (fn: (value: T) => void) => () => void; 4 | export type NotifyFn = (value: T) => void; 5 | 6 | /** 7 | * A hook for subscribing to a value. 8 | * @example 9 | * const { subscribe, notify } = useSubscribe(); 10 | * subscribe((value) => console.log(value)); 11 | * notify(1); 12 | * 13 | * @returns `subscribe` and `notify` functions. 14 | */ 15 | export function useSubscribe() : [SubscribeFn, NotifyFn] { 16 | const subscribers = useRef(new Set<(value: T) => void>()); 17 | 18 | const subscribe: SubscribeFn = useCallback((fn: (value: T) => void) => { 19 | subscribers.current.add(fn); 20 | return () => subscribers.current.delete(fn); 21 | }, []); 22 | 23 | const notify: NotifyFn = useCallback((value: T) => { 24 | subscribers.current.forEach((fn) => fn(value)); 25 | }, []); 26 | 27 | return [subscribe, notify]; 28 | } -------------------------------------------------------------------------------- /packages/blocks/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "allowSyntheticDefaultImports": true, 5 | "esModuleInterop": true, 6 | "allowJs": true, 7 | "declaration": true, 8 | "declarationDir": "./dist", 9 | "forceConsistentCasingInFileNames": true, 10 | "removeComments": false, 11 | "noUnusedLocals": true, 12 | "noUnusedParameters": true, 13 | "noImplicitReturns": true, 14 | "noFallthroughCasesInSwitch": true, 15 | "outDir": "./dist", 16 | "rootDir": "./src", 17 | "moduleResolution": "bundler", 18 | "baseUrl": ".", 19 | "skipLibCheck": true, 20 | "paths": { 21 | "@components/*": ["./src/components/*"], 22 | "@splat-viewer/*": ["./src/splat-viewer/*"], 23 | "@lib/*": ["./src/lib/*"] 24 | }, 25 | }, 26 | "include": ["src/**/*"], 27 | "exclude": ["src/**/*.test.tsx", "src/**/*.test.ts", "src/**/*.test.jsx", "src/**/*.test.js"] 28 | } 29 | -------------------------------------------------------------------------------- /packages/blocks/src/splat-viewer/camera-mode-toggle.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { Move3D, Rotate3D } from "lucide-react"; 4 | import { useAssetViewer } from "./splat-viewer-context.tsx"; 5 | import { Button } from "@components/ui/button"; 6 | import { Tooltip, TooltipContent, TooltipTrigger } from "@components/ui/tooltip"; 7 | 8 | function CameraModeToggle() { 9 | 10 | const { mode, setMode } = useAssetViewer(); 11 | 12 | return ( 13 | 14 | 25 | 26 | 27 | {mode === 'orbit' ? "Fly Camera" : "Orbit Camera"} 28 | 29 | ) 30 | } 31 | 32 | export { CameraModeToggle }; -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | *.pem 15 | 16 | # Editor directories and files 17 | .vscode/* 18 | !.vscode/extensions.json 19 | .idea 20 | .DS_Store 21 | *.suo 22 | *.ntvs* 23 | *.njsproj 24 | *.sln 25 | *.sw? 26 | 27 | # ignore splats 28 | *.ply 29 | 30 | *storybook.log 31 | 32 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 33 | 34 | # dependencies 35 | /node_modules 36 | /.pnp 37 | .pnp.* 38 | .yarn/* 39 | !.yarn/patches 40 | !.yarn/plugins 41 | !.yarn/releases 42 | !.yarn/versions 43 | 44 | # testing 45 | /coverage 46 | 47 | # next.js 48 | /**/.next/ 49 | /**/out/ 50 | 51 | # production 52 | /**/build 53 | 54 | # debug 55 | npm-debug.log* 56 | yarn-debug.log* 57 | yarn-error.log* 58 | 59 | # env files (can opt-in for committing if needed) 60 | .env* 61 | 62 | # vercel 63 | .vercel 64 | 65 | # typescript 66 | *.tsbuildinfo 67 | next-env.d.ts 68 | 69 | .olddocs 70 | .examples 71 | 72 | .next/ 73 | _pagefind/ 74 | 75 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 PlayCanvas 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /.github/workflows/renovate-changeset.yml: -------------------------------------------------------------------------------- 1 | name: Add Changeset to dependency PRs 2 | 3 | on: 4 | # Use pull_request_target so we can push to PR branches from forks (Renovate). 5 | pull_request_target: 6 | types: [opened, reopened, synchronize, labeled] 7 | 8 | permissions: 9 | contents: write # allow committing the .changeset file 10 | pull-requests: write # allow commenting/updating the PR 11 | 12 | jobs: 13 | add-changeset: 14 | # Only run for PRs labeled "dependencies" (Renovate adds this via :label(dependencies)) 15 | # and skip Changesets' own release PRs (usually titled "Version Packages") 16 | if: contains(github.event.pull_request.labels.*.name, 'dependencies') 17 | runs-on: ubuntu-latest 18 | steps: 19 | # Create or update a .changeset/*.md in the PR 20 | - name: Add a changeset based on conventional commits 21 | uses: mscharley/dependency-changesets-action@v1.2.1 22 | with: 23 | token: ${{ secrets.GITHUB_TOKEN }} 24 | use-conventional-commits: true 25 | author-name: "Renovate Changesets" 26 | author-email: "github-actions[bot]@users.noreply.github.com" -------------------------------------------------------------------------------- /packages/blocks/src/splat-viewer/controls.tsx: -------------------------------------------------------------------------------- 1 | 2 | "use client" 3 | 4 | import { useAssetViewer, useTimeline } from "./splat-viewer-context.tsx"; 5 | import { cn } from "@lib/utils"; 6 | 7 | type ControlsProps = { 8 | /** 9 | * The className of the controls 10 | */ 11 | className?: string, 12 | /** 13 | * When enabled, the controls will be hidden when the user is not interacting with the asset. 14 | * @defaultValue false 15 | */ 16 | autoHide?: boolean, 17 | /** 18 | * The children of the controls 19 | */ 20 | children: React.ReactNode, 21 | } 22 | 23 | export function Controls({ className, autoHide = false, children }: ControlsProps) { 24 | const { isInteracting } = useAssetViewer(); 25 | const { isPlaying } = useTimeline(); 26 | 27 | return ( 28 |
37 | {children} 38 |
39 | ); 40 | } -------------------------------------------------------------------------------- /packages/blocks/src/splat-viewer/download-button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { DownloadIcon } from "lucide-react"; 4 | import { Button } from "@components/ui/button"; 5 | import { useAssetViewer } from "./splat-viewer-context.tsx"; 6 | import { TooltipTrigger, TooltipContent, Tooltip } from "@components/ui/tooltip"; 7 | 8 | type DownloadButtonProps = { 9 | variant?: "default" | "outline" | "ghost" | "link"; 10 | } 11 | 12 | function DownloadButton({ variant = "ghost" }: DownloadButtonProps) { 13 | const { src, triggerDownload } = useAssetViewer(); // assume src is a URL string 14 | 15 | return ( 16 | 17 | 18 | 28 | 29 | 30 | Download 31 | 32 | 33 | ); 34 | } 35 | 36 | export { DownloadButton }; -------------------------------------------------------------------------------- /packages/lib/src/utils/synthetic-event.ts: -------------------------------------------------------------------------------- 1 | export class SyntheticPointerEvent { 2 | 3 | nativeEvent: PointerEvent; 4 | hasStoppedPropagation: boolean = false; 5 | type: string 6 | 7 | constructor(e: PointerEvent) { 8 | this.nativeEvent = e; 9 | this.type = e.type; 10 | Object.assign(this, e); 11 | } 12 | 13 | stopPropagation() { 14 | this.hasStoppedPropagation = true; 15 | this.nativeEvent.stopPropagation(); 16 | } 17 | 18 | stopImmediatePropagation() { 19 | this.hasStoppedPropagation = true; 20 | this.nativeEvent.stopImmediatePropagation(); 21 | } 22 | } 23 | export class SyntheticMouseEvent { 24 | 25 | nativeEvent: MouseEvent; 26 | hasStoppedPropagation: boolean = false; 27 | type: string 28 | 29 | constructor(e: MouseEvent) { 30 | this.nativeEvent = e; 31 | this.type = e.type; 32 | Object.assign(this, e); 33 | } 34 | 35 | stopPropagation() { 36 | this.hasStoppedPropagation = true; 37 | this.nativeEvent.stopPropagation(); 38 | } 39 | 40 | stopImmediatePropagation() { 41 | this.hasStoppedPropagation = true; 42 | this.nativeEvent.stopImmediatePropagation(); 43 | } 44 | } 45 | 46 | -------------------------------------------------------------------------------- /packages/blocks/src/splat-viewer/full-screen-button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { MaximizeIcon, MinimizeIcon } from "lucide-react"; 4 | import { Button } from "@components/ui/button"; 5 | import { useAssetViewer } from "./splat-viewer-context.tsx"; 6 | import { TooltipTrigger, TooltipContent, Tooltip } from "@components/ui/tooltip"; 7 | 8 | const FullScreenToggleIcon = ({ isFullscreen }: { isFullscreen: boolean }) => { 9 | return isFullscreen ? : ; 10 | } 11 | 12 | type FullScreenButtonProps = { 13 | variant?: "default" | "outline" | "ghost" | "link"; 14 | } 15 | 16 | function FullScreenButton({ variant = "ghost" }: FullScreenButtonProps) { 17 | const { isFullscreen, toggleFullscreen } = useAssetViewer(); 18 | 19 | return ( 20 | 21 | 24 | 25 | 26 | {isFullscreen ? "Exit Fullscreen" : "Enter Fullscreen"} 27 | 28 | ); 29 | } 30 | 31 | export { FullScreenButton }; -------------------------------------------------------------------------------- /.github/workflows/changesets.yml: -------------------------------------------------------------------------------- 1 | name: Changesets 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | permissions: 9 | contents: write 10 | pull-requests: write 11 | 12 | env: 13 | CI: true 14 | 15 | jobs: 16 | version: 17 | timeout-minutes: 15 18 | runs-on: ubuntu-latest 19 | steps: 20 | - name: Checkout code repository 21 | uses: actions/checkout@v6 22 | with: 23 | fetch-depth: 0 24 | 25 | - name: Setup pnpm 26 | uses: pnpm/action-setup@v4 27 | 28 | - name: Setup node.js 29 | uses: actions/setup-node@v6 30 | with: 31 | node-version: 24 32 | cache: 'pnpm' 33 | 34 | - name: Install dependencies 35 | run: pnpm install 36 | 37 | - name: Build packages for publishing 38 | run: pnpm run build:lib && pnpm run build:blocks 39 | 40 | - name: Create and publish versions 41 | uses: changesets/action@v1 42 | with: 43 | commit: "chore: update versions" 44 | title: "chore: update versions" 45 | publish: pnpm -w changeset publish 46 | createGithubReleases: true 47 | env: 48 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 49 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} -------------------------------------------------------------------------------- /packages/blocks/src/components/ui/switch.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as SwitchPrimitive from "@radix-ui/react-switch" 5 | 6 | import { cn } from "@lib/utils" 7 | 8 | function Switch({ 9 | className, 10 | ...props 11 | }: React.ComponentProps) { 12 | return ( 13 | 21 | 27 | 28 | ) 29 | } 30 | 31 | export { Switch } 32 | -------------------------------------------------------------------------------- /packages/lib/src/gltf/utils/schema-registry.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Component schema registry for serialization 3 | * Provides access to component schemas to know which properties to serialize 4 | */ 5 | 6 | import { lightComponentDefinition } from '../../components/Light.tsx'; 7 | import { renderComponentDefinition } from '../../components/Render.tsx'; 8 | import { cameraComponentDefinition } from '../../components/Camera.tsx'; 9 | 10 | /** 11 | * Registry mapping component type names to their schema definitions 12 | */ 13 | export const componentSchemaRegistry: Record }> = { 14 | light: lightComponentDefinition, 15 | render: renderComponentDefinition, 16 | camera: cameraComponentDefinition, 17 | // Add more as they're exported from component files 18 | }; 19 | 20 | /** 21 | * Get the list of serializable property names for a component type 22 | * Uses the actual component schema as source of truth 23 | */ 24 | export function getSerializablePropertyNames(componentType: string): string[] | null { 25 | const definition = componentSchemaRegistry[componentType]; 26 | 27 | if (!definition || !definition.schema) { 28 | // No schema found - return null to signal we should serialize all properties 29 | return null; 30 | } 31 | 32 | // Return all property keys defined in the schema 33 | return Object.keys(definition.schema); 34 | } 35 | 36 | -------------------------------------------------------------------------------- /packages/lib/src/components/Camera.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { FC } from "react"; 4 | import { useComponent } from "../hooks/index.ts"; 5 | import { CameraComponent, Entity } from "playcanvas"; 6 | import { PublicProps, Serializable } from "../utils/types-utils.ts"; 7 | import { validatePropsPartial, createComponentDefinition, getStaticNullApplication } from "../utils/validation.ts"; 8 | 9 | /** 10 | * The Camera component makes an entity behave like a camera and gives you a view into the scene. 11 | * Moving the container entity will move the camera. 12 | * 13 | * @param {CameraProps} props - The props to pass to the camera component. 14 | * 15 | * @example 16 | * 17 | * 18 | * 19 | */ 20 | export const Camera: FC = (props) => { 21 | 22 | const safeProps = validatePropsPartial(props, componentDefinition); 23 | 24 | useComponent("camera", safeProps, componentDefinition.schema); 25 | return null; 26 | 27 | } 28 | 29 | type CameraProps = Partial>>; 30 | 31 | const componentDefinition = createComponentDefinition( 32 | "Camera", 33 | () => new Entity("mock-camera", getStaticNullApplication()).addComponent('camera') as CameraComponent, 34 | (component) => (component as CameraComponent).system.destroy(), 35 | { apiName: "CameraComponent" } 36 | ) 37 | 38 | export { componentDefinition as cameraComponentDefinition }; -------------------------------------------------------------------------------- /packages/lib/src/utils/types-utils.ts: -------------------------------------------------------------------------------- 1 | import { Vec2 } from "playcanvas"; 2 | import { Color } from "playcanvas"; 3 | 4 | import { Vec4 } from "playcanvas"; 5 | import { Quat } from "playcanvas"; 6 | import { Vec3 } from "playcanvas"; 7 | 8 | type BuiltInKeys = 9 | | 'constructor' | 'prototype' | 'length' | 'name' 10 | | 'arguments' | 'caller' | 'apply' | 'bind' 11 | | 'toString' | 'valueOf' | 'hasOwnProperty' 12 | | 'isPrototypeOf' | 'propertyIsEnumerable' | 'toLocaleString'; 13 | 14 | type IfEquals = (() => T extends X ? 1 : 2) extends (() => T extends Y ? 1 : 2) ? A : B; 15 | 16 | type ReadonlyKeys = { 17 | [P in keyof T]-?: IfEquals< 18 | { [Q in P]: T[P] }, 19 | { -readonly [Q in P]: T[P] }, 20 | never, 21 | P 22 | > 23 | }[keyof T]; 24 | 25 | export type PublicProps = { 26 | [K in keyof T as 27 | K extends `_${string}` ? never : 28 | K extends `#${string}` ? never : 29 | T[K] extends (...args: unknown[]) => unknown ? never : 30 | K extends BuiltInKeys ? never : 31 | K extends ReadonlyKeys ? never : 32 | K 33 | ]: T[K]; 34 | }; 35 | 36 | export type SubclassOf = new () => T; 37 | 38 | export type Serializable = { 39 | [K in keyof T]: T[K] extends Color ? string : 40 | T[K] extends Vec2 ? [number, number] : 41 | T[K] extends Vec3 ? [number, number, number] : 42 | T[K] extends Vec4 ? [number, number, number, number] : 43 | T[K] extends Quat ? [number, number, number, number] : 44 | T[K]; 45 | }; -------------------------------------------------------------------------------- /packages/lib/src/components/Screen.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; 4 | import { Screen } from './Screen.tsx'; 5 | import { Entity } from '../Entity.tsx'; 6 | import { Application } from '../Application.tsx'; 7 | 8 | const renderWithProviders = (ui: React.ReactNode) => { 9 | return render( 10 | 11 | 12 | {ui} 13 | 14 | 15 | ); 16 | }; 17 | 18 | describe('Screen', () => { 19 | beforeEach(() => { 20 | vi.useFakeTimers(); 21 | }); 22 | 23 | afterEach(() => { 24 | vi.useRealTimers(); 25 | }); 26 | 27 | it('should render with default props', () => { 28 | const { container } = renderWithProviders(); 29 | expect(container).toBeTruthy(); 30 | }); 31 | 32 | it('should render with custom props', () => { 33 | const { container } = renderWithProviders( 34 | 39 | ); 40 | expect(container).toBeTruthy(); 41 | }); 42 | 43 | it('should throw error if used outside of Entity', () => { 44 | expect(() => { 45 | render(); 46 | }).toThrow('`useParent` must be used within an App or Entity via a ParentContext.Provider'); 47 | }); 48 | }); -------------------------------------------------------------------------------- /packages/lib/src/scripts/auto-rotator/auto-rotator.tsx: -------------------------------------------------------------------------------- 1 | import { Entity, Script } from "playcanvas"; 2 | 3 | const smoothStep = (x: number): number => 4 | x <= 0 ? 0 : x >= 1 ? 1 : Math.sin((x - 0.5) * Math.PI) * 0.5 + 0.5; 5 | 6 | 7 | class AutoRotator extends Script { 8 | static scriptName = 'autoRotator'; 9 | 10 | speed: number = 4; 11 | pitchSpeed: number = 0; 12 | pitchAmount: number = 1; 13 | startDelay: number = 4; 14 | startFadeInTime: number = 5; 15 | timer: number = 0; 16 | pitch: number = 0; 17 | yaw: number = 0; 18 | 19 | update(dt: number): void { 20 | 21 | const entity = this.entity as Entity; 22 | 23 | // @ts-expect-error The script is actually dynamic but PlayCanvas types do not reflect this. 24 | const camera = entity?.script?.orbitCamera; 25 | if (!camera) { 26 | return; 27 | } 28 | 29 | if (this.pitch !== camera.pitch || this.yaw !== camera.yaw) { 30 | // camera was moved 31 | this.pitch = camera.pitch; 32 | this.yaw = camera.yaw; 33 | this.timer = 0; 34 | } else { 35 | this.timer += dt; 36 | } 37 | 38 | if (this.timer > this.startDelay) { 39 | // animate still camera 40 | const time = this.timer - this.startDelay; 41 | const fadeIn = smoothStep(time / this.startFadeInTime); 42 | 43 | this.yaw += dt * fadeIn * this.speed; 44 | this.pitch += 45 | Math.sin(time * this.pitchSpeed) * dt * fadeIn * this.pitchAmount; 46 | 47 | camera.yaw = this.yaw; 48 | camera.pitch = this.pitch; 49 | } 50 | } 51 | } 52 | 53 | export { AutoRotator }; 54 | -------------------------------------------------------------------------------- /packages/blocks/src/splat-viewer/progress-indicator.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect, useRef, useState } from "react"; 4 | import { useAssetViewer } from "./splat-viewer-context.ts"; 5 | import { cn } from "@lib/utils"; 6 | 7 | type ProgressProps = { 8 | variant?: "top" | "bottom"; 9 | className?: string; 10 | style?: React.CSSProperties; 11 | }; 12 | 13 | export function Progress({ variant = "top", className, style }: ProgressProps) { 14 | const { subscribe } = useAssetViewer(); 15 | const ref = useRef(null); 16 | const [visible, setVisible] = useState(true); 17 | const timeoutRef = useRef | null>(null); 18 | 19 | useEffect(() => { 20 | const unsubscribe = subscribe((progress) => { 21 | if (!ref.current) return; 22 | ref.current.style.width = `${progress * 100}%`; 23 | 24 | if (progress >= 1) { 25 | timeoutRef.current = setTimeout(() => setVisible(false), 500); 26 | } else { 27 | setVisible(true); 28 | } 29 | }); 30 | 31 | return () => { 32 | unsubscribe(); 33 | if (timeoutRef.current) clearTimeout(timeoutRef.current); 34 | }; 35 | }, [subscribe]); 36 | 37 | if (!visible) return null; 38 | 39 | return ( 40 |
50 | ); 51 | } -------------------------------------------------------------------------------- /packages/blocks/src/splat-viewer/timeline.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Slider } from "@components/ui/slider"; 4 | import { useTimeline } from "./splat-viewer-context.ts"; 5 | import { Button } from "@components/ui/button"; 6 | import { PauseIcon, PlayIcon } from "lucide-react"; 7 | import { TooltipContent, TooltipTrigger } from "@components/ui/tooltip"; 8 | 9 | function PlayButton() { 10 | 11 | const { setIsPlaying, isPlaying } = useTimeline(); 12 | 13 | return ( 14 | <> 15 | 16 | 19 | 20 | 21 | { isPlaying ? "Pause" : "Play" } 22 | 23 | 24 | ) 25 | } 26 | 27 | function Timeline() { 28 | 29 | const { getTime, setTime, setIsPlaying, onCommit } = useTimeline(); 30 | 31 | const onValueChange = ([val]: number[]) => { 32 | setIsPlaying(false); 33 | setTime(val); 34 | }; 35 | 36 | const onValueCommit = ([val]: number[]) => { 37 | onCommit?.(val); 38 | } 39 | 40 | return (<> 41 | 42 | 50 | ) 51 | } 52 | 53 | export { Timeline }; -------------------------------------------------------------------------------- /packages/lib/src/components/GSplat.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { FC } from "react"; 4 | import { useComponent } from "../hooks/index.ts"; 5 | import { Asset, Entity, GSplatComponent } from "playcanvas"; 6 | import { PublicProps } from "../utils/types-utils.ts"; 7 | import { validatePropsWithDefaults, createComponentDefinition, getStaticNullApplication } from "../utils/validation.ts"; 8 | /** 9 | * The GSplat component allows an entity to render a Gaussian Splat. 10 | * @param {GSplatProps} props - The props to pass to the GSplat component. 11 | * @see https://api.playcanvas.com/engine/classes/GSplatComponent.html 12 | * @example 13 | * const { data: splat } = useSplat('./splat.ply') 14 | * 15 | */ 16 | export const GSplat: FC = (props) => { 17 | 18 | const safeProps = validatePropsWithDefaults(props, componentDefinition); 19 | useComponent("gsplat", safeProps, componentDefinition.schema); 20 | return null 21 | 22 | }; 23 | 24 | type GSplatProps = Partial> 25 | 26 | const componentDefinition = createComponentDefinition( 27 | "GSplat", 28 | () => new Entity("mock-gsplat", getStaticNullApplication()).addComponent('gsplat') as GSplatComponent, 29 | (component) => (component as GSplatComponent).system.destroy(), 30 | { apiName: "GSplatComponent" } 31 | ) 32 | 33 | componentDefinition.schema = { 34 | ...componentDefinition.schema, 35 | asset: { 36 | validate: (val: unknown) => val instanceof Asset, 37 | errorMsg: (val: unknown) => `Invalid value for prop "asset": "${JSON.stringify(val)}". Expected an Asset.`, 38 | default: null 39 | } 40 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "playcanvas-react-monorepo", 3 | "description": "A monorepo for PlayCanvas React renderer and examples. Please see the packages directory for more information.", 4 | "private": true, 5 | "license": "MIT", 6 | "packageManager": "pnpm@10.21.0", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/playcanvas/react" 10 | }, 11 | "workspaces": [ 12 | "packages/*" 13 | ], 14 | "scripts": { 15 | "dev": "pnpm -r dev", 16 | "build:lib": "pnpm --filter @playcanvas/react run build", 17 | "build:blocks": "pnpm --filter @playcanvas/blocks run build", 18 | "build:docs": "pnpm --filter docs run build", 19 | "build": "pnpm run build:lib && pnpm run build:blocks && pnpm run build:docs", 20 | "start": "pnpm --filter docs run start", 21 | "lint": "pnpm -r exec eslint .", 22 | "test": "pnpm -r run test" 23 | }, 24 | "devDependencies": { 25 | "@changesets/cli": "2.29.7", 26 | "@eslint/js": "9.39.1", 27 | "@testing-library/dom": "10.4.1", 28 | "@types/react": "19.2.2", 29 | "@types/react-dom": "19.2.2", 30 | "@vitejs/plugin-react": "5.1.0", 31 | "eslint": "9.39.1", 32 | "eslint-plugin-import": "2.32.0", 33 | "eslint-plugin-react": "7.37.5", 34 | "eslint-plugin-react-compiler": "19.0.0-beta-ebf51a3-20250411", 35 | "globals": "16.5.0", 36 | "pkg-pr-new": "0.0.60", 37 | "type-fest": "5.2.0", 38 | "typescript": "5.9.3", 39 | "typescript-eslint": "8.46.3", 40 | "vitest": "4.0.8" 41 | }, 42 | "dependencies": { 43 | "next": "16.0.7" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /packages/lib/src/components/Sprite.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { useComponent } from "../hooks/index.ts"; 3 | import { PublicProps } from "../utils/types-utils.ts"; 4 | import { Asset, Entity, SpriteComponent } from "playcanvas"; 5 | import { createComponentDefinition, validatePropsWithDefaults, Schema, getStaticNullApplication } from "../utils/validation.ts"; 6 | 7 | /** 8 | * The Sprite component allows an entity to render a 2D sprite. 9 | * 10 | * @param {SpriteProps} props - The props to pass to the sprite component. 11 | * @see https://api.playcanvas.com/engine/classes/SpriteComponent.html 12 | * @example 13 | * 14 | * 15 | * 16 | */ 17 | export const Sprite: FC = (props) => { 18 | 19 | const safeProps = validatePropsWithDefaults(props, componentDefinition); 20 | 21 | useComponent("sprite", safeProps, componentDefinition.schema); 22 | return null; 23 | } 24 | 25 | interface SpriteProps extends Partial> { 26 | asset : Asset 27 | } 28 | 29 | const componentDefinition = createComponentDefinition( 30 | "Sprite", 31 | () => new Entity("mock-sprite", getStaticNullApplication()).addComponent('sprite') as SpriteComponent, 32 | (component) => (component as SpriteComponent).system.destroy(), 33 | { apiName: "SpriteComponent" } 34 | ) 35 | 36 | componentDefinition.schema = { 37 | ...componentDefinition.schema, 38 | asset: { 39 | validate: (value: unknown) => value instanceof Asset, 40 | errorMsg: (value: unknown) => `Invalid value for prop "asset": "${value}". Expected an Asset.`, 41 | default: null 42 | } 43 | } as Schema -------------------------------------------------------------------------------- /packages/blocks/src/components/ui/toggle.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as TogglePrimitive from "@radix-ui/react-toggle" 5 | import { cva, type VariantProps } from "class-variance-authority" 6 | 7 | import { cn } from "@lib/utils" 8 | 9 | const toggleVariants = cva( 10 | "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", 11 | { 12 | variants: { 13 | variant: { 14 | default: "bg-transparent", 15 | outline: 16 | "border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground", 17 | }, 18 | size: { 19 | default: "h-9 px-2 min-w-9", 20 | sm: "h-8 px-1.5 min-w-8", 21 | lg: "h-10 px-2.5 min-w-10", 22 | }, 23 | }, 24 | defaultVariants: { 25 | variant: "default", 26 | size: "default", 27 | }, 28 | } 29 | ) 30 | 31 | function Toggle({ 32 | className, 33 | variant, 34 | size, 35 | ...props 36 | }: React.ComponentProps & 37 | VariantProps) { 38 | return ( 39 | 44 | ) 45 | } 46 | 47 | export { Toggle, toggleVariants } 48 | -------------------------------------------------------------------------------- /packages/lib/test/README.md: -------------------------------------------------------------------------------- 1 | # Testing Guidelines 2 | 3 | ## Structure 4 | - `utils/`: Test utilities and helper functions 5 | - `constants.ts`: Shared test constants and fixtures 6 | - `setup.ts`: Global test setup and configuration 7 | 8 | ## Best Practices 9 | 10 | ### 1. Test Organization 11 | - Tests should be co-located with their components 12 | - Use descriptive test names that explain the behavior being tested 13 | - Group related tests using `describe` blocks 14 | - Use `beforeEach` and `afterEach` for setup and cleanup 15 | 16 | ### 2. Test Utilities 17 | - Use `renderWithProviders` for components that need the Application context 18 | - Use shared mock functions from `utils/index.ts` 19 | - Use test constants from `constants.ts` for consistent test data 20 | 21 | ### 3. Mocking 22 | - Mock external dependencies using `vi.mock()` 23 | - Use `vi.clearAllMocks()` in `afterEach` to ensure clean state 24 | - Mock only what's necessary for the test 25 | 26 | ### 4. Testing Patterns 27 | ```typescript 28 | describe('Component', () => { 29 | beforeEach(() => { 30 | // Setup 31 | }); 32 | 33 | afterEach(() => { 34 | // Cleanup 35 | }); 36 | 37 | it('should do something specific', () => { 38 | // Arrange 39 | // Act 40 | // Assert 41 | }); 42 | }); 43 | ``` 44 | 45 | ### 5. Coverage 46 | - Maintain minimum coverage thresholds: 47 | - Lines: 80% 48 | - Functions: 80% 49 | - Branches: 80% 50 | - Statements: 80% 51 | 52 | ### 6. Custom Matchers 53 | - Use `toHaveBeenCalledWithProps` to verify mock calls with specific props 54 | - Extend with additional matchers as needed 55 | 56 | ## Running Tests 57 | ```bash 58 | # Run all tests 59 | npm test 60 | 61 | # Run tests in watch mode 62 | npm test -- --watch 63 | 64 | # Run tests with coverage 65 | npm test -- --coverage 66 | ``` -------------------------------------------------------------------------------- /packages/blocks/src/components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@lib/utils" 6 | 7 | const badgeVariants = cva( 8 | "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", 14 | secondary: 15 | "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", 16 | destructive: 17 | "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", 18 | outline: 19 | "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", 20 | }, 21 | }, 22 | defaultVariants: { 23 | variant: "default", 24 | }, 25 | } 26 | ) 27 | 28 | function Badge({ 29 | className, 30 | variant, 31 | asChild = false, 32 | ...props 33 | }: React.ComponentProps<"span"> & 34 | VariantProps & { asChild?: boolean }) { 35 | const Comp = asChild ? Slot : "span" 36 | 37 | return ( 38 | 43 | ) 44 | } 45 | 46 | export { Badge, badgeVariants } 47 | -------------------------------------------------------------------------------- /packages/lib/src/hooks/use-component.tsx: -------------------------------------------------------------------------------- 1 | import { useLayoutEffect, useRef } from "react"; 2 | import { useParent } from "./use-parent.tsx"; 3 | import { useApp } from "./use-app.tsx"; 4 | import { Application, Component, Entity } from "playcanvas"; 5 | import { applyProps, Schema } from "../utils/validation.ts"; 6 | 7 | export function useComponent( 8 | ctype: string | null, 9 | props: T, 10 | schema: Schema 11 | ): void { 12 | const componentRef = useRef(null); 13 | const parent : Entity = useParent(); 14 | const app : Application = useApp(); 15 | 16 | useLayoutEffect(() => { 17 | if(!ctype) { 18 | return; 19 | } 20 | 21 | if (parent) { 22 | // Only add the component if it hasn't been added yet 23 | if (!componentRef.current) { 24 | componentRef.current = parent.addComponent(ctype); 25 | } 26 | } 27 | 28 | return () => { 29 | const comp = componentRef.current 30 | componentRef.current = null; 31 | 32 | if(!app || !app.root) return; 33 | 34 | if (comp) { 35 | type SystemKeys = keyof typeof app.systems; 36 | if (app.systems[ctype as SystemKeys] && parent.c[ctype]) { 37 | parent.removeComponent(ctype); 38 | } 39 | } 40 | }; 41 | }, [app, parent, ctype]); 42 | 43 | // Update component props 44 | useLayoutEffect(() => { 45 | 46 | if(!ctype) { 47 | return 48 | } 49 | 50 | const comp: Component | null | undefined = componentRef.current 51 | // Ensure componentRef.current exists before updating props 52 | if (!comp) return; 53 | 54 | const filteredProps = Object.fromEntries( 55 | Object.entries(props as Record).filter(([key]) => key in comp) 56 | ); 57 | 58 | applyProps(comp as InstanceType, schema, filteredProps as Record); 59 | 60 | }); 61 | }; -------------------------------------------------------------------------------- /packages/lib/src/scripts/orbit-controls/index.tsx: -------------------------------------------------------------------------------- 1 | import { Entity, Vec3 } from "playcanvas"; 2 | import { Script } from "../../components/Script.tsx"; 3 | import { OrbitCamera, OrbitCameraInputMouse, OrbitCameraInputTouch } from "./orbit-camera.js"; 4 | import { warnOnce } from "../../utils/validation.ts"; 5 | 6 | type OrbitCameraProps = { 7 | distanceMax?: number 8 | distanceMin?: number 9 | pitchAngleMax?: number 10 | pitchAngleMin?: number 11 | inertiaFactor?: number 12 | focusEntity?: Entity | null 13 | frameOnStart?: boolean 14 | distance?: number 15 | pivotPoint?: Vec3 | null 16 | } 17 | 18 | type OrbitCameraInputProps = { 19 | orbitSensitivity?: number; 20 | distanceSensitivity?: number; 21 | }; 22 | 23 | type OrbitControls = OrbitCameraProps & { 24 | mouse?: OrbitCameraInputProps; 25 | touch?: OrbitCameraInputProps; 26 | }; 27 | 28 | export const OrbitControls = ({ 29 | distanceMax = 20, distanceMin = 18, pitchAngleMax = 90, pitchAngleMin = 0, inertiaFactor = 0.0, focusEntity = null, pivotPoint = new Vec3(), frameOnStart = true, distance = 0, 30 | mouse = { orbitSensitivity: 0.3, distanceSensitivity: 0.15 }, 31 | touch = { orbitSensitivity: 0.4, distanceSensitivity: 0.2 }, 32 | } : OrbitControls) => { 33 | 34 | warnOnce('The `` component is deprecated and will be removed in a future release. Use the PlayCanvas `CameraControls` script from `playcanvas` via the `