From 22a540a96e21d72ddf542a33d61a11b0c4b772db Mon Sep 17 00:00:00 2001 From: Mark Lundin Date: Fri, 28 Mar 2025 15:21:23 +0000 Subject: [PATCH 1/5] Refactor component props and enhance type safety - Updated MotionEntity to use tuple types for position, rotation, and scale. - Refactored Application, Entity, and various component files to extend PublicProps for better type safety. - Removed deprecated warnOnce utility and improved error handling in components. - Cleaned up commented-out code and improved documentation for clarity. - Enhanced color handling in useMaterial and useColors hooks to support CSS color formats. --- packages/docs/src/components/MotionEntity.tsx | 6 +- packages/lib/src/Application.tsx | 44 +++---- packages/lib/src/Container.tsx | 2 +- packages/lib/src/Entity.tsx | 111 ++++++++++++++++-- packages/lib/src/components/Anim.tsx | 8 +- packages/lib/src/components/Camera.tsx | 10 +- packages/lib/src/components/Collision.tsx | 17 ++- packages/lib/src/components/GSplat.tsx | 7 +- packages/lib/src/components/Light.tsx | 6 +- packages/lib/src/components/Render.tsx | 13 +- packages/lib/src/components/RigidBody.tsx | 17 ++- packages/lib/src/components/Script.tsx | 8 -- packages/lib/src/components/Sprite.tsx | 8 +- packages/lib/src/hooks/use-component.tsx | 2 +- packages/lib/src/hooks/use-material.tsx | 22 ++-- packages/lib/src/utils/color.ts | 67 ++++++++--- packages/lib/src/utils/types-utils.ts | 28 +++++ packages/lib/src/utils/validation.ts | 69 +++++++++++ packages/lib/src/utils/warn-once.ts | 13 -- 19 files changed, 334 insertions(+), 124 deletions(-) create mode 100644 packages/lib/src/utils/types-utils.ts create mode 100644 packages/lib/src/utils/validation.ts delete mode 100644 packages/lib/src/utils/warn-once.ts diff --git a/packages/docs/src/components/MotionEntity.tsx b/packages/docs/src/components/MotionEntity.tsx index 818ea74b..fdb1ba24 100644 --- a/packages/docs/src/components/MotionEntity.tsx +++ b/packages/docs/src/components/MotionEntity.tsx @@ -79,9 +79,9 @@ export const MotionEntity: FC = ({ children, animate: animate return ( {children} diff --git a/packages/lib/src/Application.tsx b/packages/lib/src/Application.tsx index 61ec7d5e..62de6eb9 100644 --- a/packages/lib/src/Application.tsx +++ b/packages/lib/src/Application.tsx @@ -1,4 +1,4 @@ -import React, { FC, PropsWithChildren, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import React, { FC, /*PropsWithChildren,*/ useLayoutEffect, useMemo, useRef, useState } from 'react'; import { FILLMODE_NONE, FILLMODE_FILL_WINDOW, @@ -9,36 +9,17 @@ import { TouchDevice, Entity as PcEntity, RESOLUTION_FIXED, + GraphicsDevice, } from 'playcanvas'; import { AppContext, ParentContext } from './hooks'; import { PointerEventsContext } from './contexts/pointer-events-context'; import { usePicker } from './utils/picker'; import { PhysicsProvider } from './contexts/physics-context'; +import { PublicProps } from './utils/types-utils'; -interface GraphicsOptions { - /** Boolean that indicates if the canvas contains an alpha buffer. */ - alpha?: boolean, //true, - /** Boolean that indicates that the drawing buffer is requested to have a depth buffer of at least 16 bits. */ - depth?: boolean, //true - /** Boolean that indicates that the drawing buffer is requested to have a stencil buffer of at least 8 bits. */ - stencil?: boolean, //true - /** Boolean that indicates whether or not to perform anti-aliasing if possible. */ - antialias?: boolean, //true - /** Boolean that indicates that the page compositor will assume the drawing buffer contains colors with pre-multiplied alpha. */ - premultipliedAlpha?: boolean, //true - /** If the value is true the buffers will not be cleared and will preserve their values until cleared or overwritten by the author. */ - preserveDrawingBuffer?: boolean, //false - /** A hint to the user agent indicating what configuration of GPU is suitable for the WebGL context. */ - powerPreference?: 'default' | 'high-performance' | 'low-power' // 'default' - /** Boolean that indicates if a context will be created if the system performance is low or if no hardware GPU is available. */ - failIfMajorPerformanceCaveat?: boolean,//false - /** Boolean that hints the user agent to reduce the latency by desynchronizing the canvas paint cycle from the event loop. */ - desynchronized?: boolean,//false - /** BBoolean that hints to the user agent to use a compatible graphics adapter for an immersive XR device. */ - xrCompatible?: boolean,//false -} +type GraphicsOptions = Partial> -interface ApplicationProps extends PropsWithChildren { +interface ApplicationProps extends Partial> { /** The class name to attach to the canvas component */ className?: string, /** A style object added to the canvas component */ @@ -47,14 +28,16 @@ interface ApplicationProps extends PropsWithChildren { 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. */ resolutionMode?: typeof RESOLUTION_AUTO | typeof RESOLUTION_FIXED - /** Clamps per-frame delta time to an upper bound. Useful since returning from a tab deactivation can generate huge values for dt, which can adversely affect game state. */ - maxDeltaTime?: number - /** Scales the global time delta. */ - timeScale?: number, + // /** Clamps per-frame delta time to an upper bound. Useful since returning from a tab deactivation can generate huge values for dt, which can adversely affect game state. */ + // maxDeltaTime?: number + // /** Scales the global time delta. */ + // timeScale?: number, /** Whether to use the PlayCanvas Physics system. */ usePhysics?: boolean, /** Graphics Settings */ - graphicsDeviceOptions?: GraphicsOptions + graphicsDeviceOptions?: GraphicsOptions, + /** The children of the application */ + children?: React.ReactNode, } @@ -102,6 +85,7 @@ export const ApplicationWithoutCanvas: FC = ({ usePhysics = false, ...otherProps }) => { + const graphicsDeviceOptions = { alpha: true, depth: true, @@ -152,7 +136,7 @@ export const ApplicationWithoutCanvas: FC = ({ if (!app) return; app.maxDeltaTime = maxDeltaTime; app.timeScale = timeScale; - }, [app]) + }, [app, maxDeltaTime, timeScale]) if (!app) return null; diff --git a/packages/lib/src/Container.tsx b/packages/lib/src/Container.tsx index 5902683a..cf750a21 100644 --- a/packages/lib/src/Container.tsx +++ b/packages/lib/src/Container.tsx @@ -44,7 +44,7 @@ export const Container: FC = ({ asset, children, ...props }) => if(!asset?.resource) return null; - return + return { children } ; }; diff --git a/packages/lib/src/Entity.tsx b/packages/lib/src/Entity.tsx index 2d858cab..e46a3339 100644 --- a/packages/lib/src/Entity.tsx +++ b/packages/lib/src/Entity.tsx @@ -5,39 +5,130 @@ import { ReactNode, forwardRef, useImperativeHandle, useLayoutEffect, useMemo } import { useParent, ParentContext, useApp } from './hooks'; import { SyntheticMouseEvent, SyntheticPointerEvent } from './utils/synthetic-event'; import { usePointerEventsContext } from './contexts/pointer-events-context'; +import { PublicProps } from './utils/types-utils'; +import { PropSchemaDefinition, validateAndSanitizeProps } from './utils/validation'; type PointerEventCallback = (event: SyntheticPointerEvent) => void; type MouseEventCallback = (event: SyntheticMouseEvent) => void; -interface EntityProps { +export interface EntityProps extends Partial> { name?: string; - position?: number[]; - scale?: number[]; - rotation?: number[]; + position?: [number, number, number]; + scale?: [number, number, number]; + rotation?: [number, number, number, number?]; onPointerUp?: PointerEventCallback; onPointerDown?: PointerEventCallback; onPointerOver?: PointerEventCallback; onPointerOut?: PointerEventCallback; onClick?: MouseEventCallback; - children?: ReactNode + children?: ReactNode; } - +/** + * The Entity component is the fundamental building block of a PlayCanvas scene. + * It represents a node in the scene graph and can have components attached to it. + * + * @example + * // Basic usage + * + * + * + * + * @param {Object} props - Component props + * @param {string} [props.name="Untitled"] - The name of the entity + * @param {number[] | Vec3} [props.position=[0,0,0]] - The local position + * @param {number[] | Vec3} [props.scale=[1,1,1]] - The local scale + * @param {number[] | Quat} [props.rotation=[0,0,0,1]] - The local rotation + * @param {boolean} [props.enabled=true] - Whether the entity is enabled + * @param {function} [props.onPointerDown] - Pointer down event handler + * @param {function} [props.onPointerUp] - Pointer up event handler + * @param {function} [props.onPointerOver] - Pointer over event handler + * @param {function} [props.onPointerOut] - Pointer out event handler + * @param {function} [props.onClick] - Click event handler + * @param {React.ReactNode} [props.children] - Child components + * @param {React.Ref} ref - Ref to access the underlying PlayCanvas Entity + * @returns {React.ReactElement} The Entity component + */ export const Entity = forwardRef (function Entity( - { + props, + ref +) : React.ReactElement | null { + + const schema: { + [K in keyof EntityProps]?: PropSchemaDefinition + } = { + name: { + validate: (val: unknown) => typeof val === 'string', + errorMsg: (val: unknown) => `Invalid "name" prop: expected a string, got ${typeof val}`, + default: 'Untitled' + }, + position: { + validate: (val: unknown) => Array.isArray(val) && val.length === 3, + errorMsg: (val: unknown) => `Invalid "position" prop: expected an array of 3 numbers, got ${typeof val}`, + default: [0, 0, 0], + }, + rotation: { + validate: (val: unknown) => Array.isArray(val) && (val.length === 3 || val.length === 4), + errorMsg: (val: unknown) => `Invalid "rotation" prop: expected an array of 3 or 4 numbers, got ${typeof val}`, + default: [0, 0, 0, 1] + }, + scale: { + validate: (val: unknown) => Array.isArray(val) && val.length === 3, + errorMsg: (val: unknown) => `Invalid "scale" prop: expected array of 3 numbers, got ${typeof val}`, + default: [1, 1, 1] + }, + onPointerDown: { + validate: (val: unknown) => typeof val === 'function', + errorMsg: (val: unknown) => `Invalid "onPointerDown" prop: expected a function, got ${typeof val}`, + default: undefined + }, + onPointerUp: { + validate: (val: unknown) => typeof val === 'function', + errorMsg: (val: unknown) => `Invalid "onPointerUp" prop: expected a function, got ${typeof val}`, + default: undefined + }, + onPointerOver: { + validate: (val: unknown) => typeof val === 'function', + errorMsg: (val: unknown) => `Invalid "onPointerOver" prop: expected a function, got ${typeof val}`, + default: undefined + }, + onPointerOut: { + validate: (val: unknown) => typeof val === 'function', + errorMsg: (val: unknown) => `Invalid "onPointerOut" prop: expected a function, got ${typeof val}`, + default: undefined + }, + onClick: { + validate: (val: unknown) => typeof val === 'function', + errorMsg: (val: unknown) => `Invalid "onClick" prop: expected a function, got ${typeof val}`, + default: undefined + }, + }; + + const safeProps = validateAndSanitizeProps(props, schema); + + const { + /** The name of the entity */ name = 'Untitled', + /** Child components */ children, + /** 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, - }, - ref -) : React.ReactElement | null { + } = safeProps; + const parent = useParent(); const app = useApp(); const pointerEvents = usePointerEventsContext(); diff --git a/packages/lib/src/components/Anim.tsx b/packages/lib/src/components/Anim.tsx index 3eba694e..883ff1ba 100644 --- a/packages/lib/src/components/Anim.tsx +++ b/packages/lib/src/components/Anim.tsx @@ -2,10 +2,12 @@ import { FC, useLayoutEffect } from "react"; import { useComponent, useParent } from "../hooks"; -import { AnimComponent, Asset, Entity } from "playcanvas"; +import { AnimComponent, Asset, Entity, LightComponent } from "playcanvas"; +import { PublicProps } from "../utils/types-utils"; +import { WithCssColors } from "../utils/color"; -interface AnimProps { - [key: string]: unknown; + +interface AnimProps extends Partial>> { asset : Asset } diff --git a/packages/lib/src/components/Camera.tsx b/packages/lib/src/components/Camera.tsx index 7f6659d4..5b6cd441 100644 --- a/packages/lib/src/components/Camera.tsx +++ b/packages/lib/src/components/Camera.tsx @@ -2,13 +2,11 @@ import { FC } from "react"; import { useComponent } from "../hooks"; -import { Color } from "playcanvas"; -import { useColors } from "../utils/color"; +import { CameraComponent } from "playcanvas"; +import { useColors, WithCssColors } from "../utils/color"; +import { PublicProps } from "../utils/types-utils"; -interface CameraProps { - [key: string]: unknown; - clearColor?: Color | string -} +type CameraProps = Partial>>; /** * The Camera component is used to define the position and properties of a camera entity. diff --git a/packages/lib/src/components/Collision.tsx b/packages/lib/src/components/Collision.tsx index 0fd55c8a..9272dc13 100644 --- a/packages/lib/src/components/Collision.tsx +++ b/packages/lib/src/components/Collision.tsx @@ -3,11 +3,18 @@ import { FC, useEffect } from "react"; import { useComponent, useParent } from "../hooks"; import { usePhysics } from "../contexts/physics-context"; -import { warnOnce } from "../utils/warn-once"; - -type CollisionProps = { - [key: string]: unknown; - type?: string; +import { warnOnce } from "../utils/validation"; +import { CollisionComponent } from "playcanvas"; +import { PublicProps } from "../utils/types-utils"; + +interface CollisionProps extends Partial> { + type?: "box" + | "capsule" + | "compound" + | "cone" + | "cylinder" + | "mesh" + | "sphere" } export const Collision: FC = (props) => { diff --git a/packages/lib/src/components/GSplat.tsx b/packages/lib/src/components/GSplat.tsx index 4a9e4f70..e315e926 100644 --- a/packages/lib/src/components/GSplat.tsx +++ b/packages/lib/src/components/GSplat.tsx @@ -2,12 +2,13 @@ import { FC, useLayoutEffect, useRef } from "react"; import { useParent } from "../hooks"; -import { Asset, Entity } from "playcanvas"; +import { Asset, Entity, GSplatComponent } from "playcanvas"; +import { PublicProps } from "../utils/types-utils"; -interface GSplatProps { +interface GSplatProps extends Partial> { + asset: Asset; vertex?: string; fragment?: string; - asset: Asset; } export const GSplat: FC = ({ vertex, fragment, asset }) => { diff --git a/packages/lib/src/components/Light.tsx b/packages/lib/src/components/Light.tsx index 27de0098..71777fa5 100644 --- a/packages/lib/src/components/Light.tsx +++ b/packages/lib/src/components/Light.tsx @@ -1,8 +1,10 @@ import { FC } from "react"; import { useComponent } from "../hooks"; -import { useColors } from "../utils/color"; +import { useColors, WithCssColors } from "../utils/color"; +import { LightComponent } from "playcanvas"; +import { PublicProps } from "../utils/types-utils"; -type LightProps = { +interface LightProps extends Partial>> { type: "directional" | "omni" | "spot"; } diff --git a/packages/lib/src/components/Render.tsx b/packages/lib/src/components/Render.tsx index 94b24913..c89c2522 100644 --- a/packages/lib/src/components/Render.tsx +++ b/packages/lib/src/components/Render.tsx @@ -4,15 +4,20 @@ import { FC } from "react"; import { useComponent } from "../hooks"; import { Container } from "../Container"; import { Asset } from "playcanvas"; +import { type RenderComponent as PcRenderComponent } from "playcanvas"; +import { PublicProps } from "../utils/types-utils"; +import { ComponentProps } from "../hooks/use-component"; -interface RenderProps { +type RenderComponentType = Partial>; +type RenderComponentTypeWithoutAsset = Omit; + +interface RenderProps extends RenderComponentTypeWithoutAsset { type: string; asset?: Asset; children?: React.ReactNode; - [key: string]: unknown; } -const RenderComponent: FC = (props) => { +const RenderComponent: FC = (props) => { useComponent("render", props); return null; } @@ -22,7 +27,7 @@ const RenderComponent: FC = (props) => { * it will be rendered as a container. Otherwise, it will be rendered as a * render component. */ -export const Render: FC = (props) => { +export const Render: FC = (props : RenderProps) => { // Render a container if the asset is a container if (props.asset?.type === 'container') { diff --git a/packages/lib/src/components/RigidBody.tsx b/packages/lib/src/components/RigidBody.tsx index ab81dba1..bd7b4d7a 100644 --- a/packages/lib/src/components/RigidBody.tsx +++ b/packages/lib/src/components/RigidBody.tsx @@ -1,11 +1,18 @@ import { FC, useEffect } from "react"; import { useComponent, useParent } from "../hooks"; import { usePhysics } from "../contexts/physics-context"; -import { warnOnce } from "../utils/warn-once"; - -type RigidBodyProps = { - [key: string]: unknown; - type?: string; +import { warnOnce } from "../utils/validation"; +import { PublicProps } from "../utils/types-utils"; +import { RigidBodyComponent } from "playcanvas"; + +interface RigidBodyProps extends Partial> { + type?: "box" + | "capsule" + | "compound" + | "cone" + | "cylinder" + | "mesh" + | "sphere" } export const RigidBody: FC = (props) => { diff --git a/packages/lib/src/components/Script.tsx b/packages/lib/src/components/Script.tsx index f29f7716..e9889bba 100644 --- a/packages/lib/src/components/Script.tsx +++ b/packages/lib/src/components/Script.tsx @@ -3,14 +3,6 @@ import { useScript } from "../hooks" import { FC, memo, useMemo } from "react"; import { shallowEquals } from "../utils/shallow-equals"; -// type PcScriptWithoutPrivateName = Omit & { -// __name: string; -// }; -// type PcScriptWithoutPrivateName = { -// new (args: { app: AppBase; entity: Entity; }): PcScript -// __name: string; -// }; - interface ScriptProps { script: new (args: { app: AppBase; entity: Entity; }) => PcScript; [key: string]: unknown; diff --git a/packages/lib/src/components/Sprite.tsx b/packages/lib/src/components/Sprite.tsx index 1404200d..3604abeb 100644 --- a/packages/lib/src/components/Sprite.tsx +++ b/packages/lib/src/components/Sprite.tsx @@ -1,11 +1,13 @@ import { FC } from "react"; import { useComponent } from "../hooks"; +import { PublicProps } from "../utils/types-utils"; +import { type Asset, SpriteComponent } from "playcanvas"; -interface SpriteProps { - [key: string]: unknown; +interface SpriteProps extends Partial> { + asset : Asset } -export const Sprite: FC = (props) => { +export const Sprite: FC = ({ ...props }) => { useComponent("sprite", props); return null; diff --git a/packages/lib/src/hooks/use-component.tsx b/packages/lib/src/hooks/use-component.tsx index 02ac30fa..9f25ce85 100644 --- a/packages/lib/src/hooks/use-component.tsx +++ b/packages/lib/src/hooks/use-component.tsx @@ -3,7 +3,7 @@ import { useParent } from "./use-parent"; import { useApp } from "./use-app"; import { Application, Component, Entity } from "playcanvas"; -type ComponentProps = { +export type ComponentProps = { [key: string]: unknown; } diff --git a/packages/lib/src/hooks/use-material.tsx b/packages/lib/src/hooks/use-material.tsx index dbf850bb..f13cfe73 100644 --- a/packages/lib/src/hooks/use-material.tsx +++ b/packages/lib/src/hooks/use-material.tsx @@ -1,25 +1,21 @@ import { useLayoutEffect, useMemo } from 'react'; import { StandardMaterial } from 'playcanvas'; import { useApp } from './use-app'; -import { useColors } from '../utils/color'; +import { getColorPropertyNames, useColors, WithCssColors } from '../utils/color'; +import { PublicProps } from '../utils/types-utils'; -type WritableKeys = { - [K in keyof T]: T[K] extends { readonly [key: string]: unknown } ? never : K; -}[keyof T]; +type MaterialProps = Partial>>; -type MaterialProps = Pick>; +// dynamically build a list of property names that are colors +const tmpMaterial: StandardMaterial = new StandardMaterial(); +const colors = getColorPropertyNames(tmpMaterial); +tmpMaterial.destroy(); export const useMaterial = (props: MaterialProps): StandardMaterial => { const app = useApp(); - const colorProps = useColors(props, [ - 'ambient', - 'attenuation', - 'diffuse', - 'emissive', - 'sheen', - 'specular' - ]); + // Get color props with proper type checking + const colorProps = useColors(props, colors as Array); const propsWithColors = { ...props, ...colorProps }; diff --git a/packages/lib/src/utils/color.ts b/packages/lib/src/utils/color.ts index 1ed7f8bd..6e6a7590 100644 --- a/packages/lib/src/utils/color.ts +++ b/packages/lib/src/utils/color.ts @@ -1,5 +1,9 @@ import { Color } from "playcanvas" import { useRef } from "react"; +import { validateAndSanitize } from "./validation"; + +// Match 3, 4, 6 or 8 character hex strings with optional # +const hexColorRegex = /^#?([0-9A-Fa-f]{3}|[0-9A-Fa-f]{4}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/; const cssColorNamesMap : Map = new Map([ ['aliceblue', '#F0F8FF'], @@ -152,13 +156,33 @@ const cssColorNamesMap : Map = new Map([ ['yellowgreen', '#9ACD32'] ]); +/** + * Convenience function that returns an array of property names that are instances of the PlayCanvas Color class + * @returns {string[]} - An array of property names + */ +export const getColorPropertyNames = (target: T): Array => { + const colorNames: string[] = Object.entries(target).reduce((arr: string[], [name, value]) => { + if (value instanceof Color ){ + return [...arr, name] + } else{ + return arr + } + }, []); + + return colorNames as Array; +}; + /** * Custom hook to process multiple color properties efficiently. * @param props The component props containing the color properties. * @param colorPropNames An array of prop names that are colors. * @returns An object mapping color prop names to their processed Color instances. */ -export const useColors = (props: Record, colorPropNames: string[]): { [key: string]: Color } => { +export const useColors = ( + props: T, + colorPropNames: Array +): { [K in typeof colorPropNames[number]]: Color } => { + const colorRefs = useRef<{ [key: string]: Color }>({}); // Filter colorPropNames to include only those keys that exist in props @@ -174,21 +198,36 @@ export const useColors = (props: Record, colorPropNames: string colorInstance = new Color(); colorRefs.current[propName] = colorInstance; } - - if (typeof value === "string") { - // Parse the color string and update the existing Color instance - const colorString = cssColorNamesMap.get(value) ?? value; - colorInstance.fromString(colorString); - } else if (value instanceof Color) { - // Copy the value into the existing Color instance - colorInstance.copy(value); - } else { - console.warn(`Invalid color value for prop ${propName}:`, value); - } + + const validatedColorString = validateAndSanitize(value, { + validate: (val: unknown) => typeof val === 'string' && (hexColorRegex.test(val) || cssColorNamesMap.has(val)), + errorMsg: (val: unknown) => `Invalid color value for prop ${propName}: "${val}". ` + + `Valid formats include: hex (#FFFFF, #FFFFFF66), ` + + `or a css color name like "red", "blue", "rebeccapurple", etc.`, + default: '#ff00ff' + }); + + colorInstance.fromString(validatedColorString); acc[propName] = colorInstance; return acc; }, {} as { [key: string]: Color }); - return processedColors; - }; \ No newline at end of file + return processedColors as { [K in typeof colorPropNames[number]]: Color }; + }; + + // Extract color names directly from your Map +type CssColorName = keyof { + [K in string as K extends keyof typeof cssColorNamesMap ? K : never]: unknown +}; + +// Define valid CSS color formats +type HexColor = `#${string}`; + +// Combine all valid CSS color types +type CssColor = CssColorName | HexColor + +// Utility to replace pc.Color with CssColor +export type WithCssColors = { + [K in keyof T]: T[K] extends Color ? CssColor : T[K]; +}; diff --git a/packages/lib/src/utils/types-utils.ts b/packages/lib/src/utils/types-utils.ts new file mode 100644 index 00000000..aaa468df --- /dev/null +++ b/packages/lib/src/utils/types-utils.ts @@ -0,0 +1,28 @@ +type BuiltInKeys = + | 'constructor' | 'prototype' | 'length' | 'name' + | 'arguments' | 'caller' | 'apply' | 'bind' + | 'toString' | 'valueOf' | 'hasOwnProperty' + | 'isPrototypeOf' | 'propertyIsEnumerable' | 'toLocaleString'; + +type IfEquals = (() => T extends X ? 1 : 2) extends (() => T extends Y ? 1 : 2) ? A : B; + +type ReadonlyKeys = { + [P in keyof T]-?: IfEquals< + { [Q in P]: T[P] }, + { -readonly [Q in P]: T[P] }, + never, + P + > + }[keyof T]; + +export type PublicProps = { + [K in keyof T as + K extends `_${string}` ? never : + K extends `#${string}` ? never : + K extends `c` ? never : + T[K] extends (...args: unknown[]) => unknown ? never : + K extends BuiltInKeys ? never : + K extends ReadonlyKeys ? never : + K + ]: T[K]; +}; diff --git a/packages/lib/src/utils/validation.ts b/packages/lib/src/utils/validation.ts new file mode 100644 index 00000000..073cfdf7 --- /dev/null +++ b/packages/lib/src/utils/validation.ts @@ -0,0 +1,69 @@ +const warned = new Set(); + +export const warnOnce = (message: string, developmentOnly = false) => { + if (!warned.has(message)) { + if (process.env.NODE_ENV === 'development') { + console.warn(message); + } else if (!developmentOnly) { + // In production, use console.error for better visibility + console.error(message); + } + warned.add(message); + } +}; + +// export function validateAndSanitize( +// props: T, +// key: K, +// validator: (value: any) => boolean, +// defaultValue: any, +// errorMessage?: string +// ): T[K] { +// const value = props[key]; +// const isValid = validator(value); + +// // Log warning in development if invalid +// if (!isValid && process.env.NODE_ENV !== 'production' && errorMessage) { +// console.warn(errorMessage); +// } + +// // Return original value if valid, default if not +// return isValid ? value : defaultValue; +// } + +export type PropSchemaDefinition = { + validate: (value: unknown) => boolean; + errorMsg: (value: unknown) => string; + default: T; +} + +export function validateAndSanitize( + value: unknown, + propDef: PropSchemaDefinition +): T { + const isValid = value !== undefined && propDef.validate(value); + + if (!isValid && value !== undefined && process.env.NODE_ENV !== 'production') { + console.warn(propDef.errorMsg(value)); + } + + return isValid ? (value as T) : propDef.default; +} + +export function validateAndSanitizeProps>( + rawProps: Partial, + schema: { + [K in keyof T]: PropSchemaDefinition; + } +): T { + const result = {} as T; + const keys = Object.keys(schema) as Array; + keys.forEach((key) => { + const propDef = schema[key]; + if (propDef) { + result[key] = validateAndSanitize(rawProps[key], propDef); + } + }); + + return result; +} diff --git a/packages/lib/src/utils/warn-once.ts b/packages/lib/src/utils/warn-once.ts deleted file mode 100644 index 3eee5442..00000000 --- a/packages/lib/src/utils/warn-once.ts +++ /dev/null @@ -1,13 +0,0 @@ -const warned = new Set(); - -export const warnOnce = (message: string, developmentOnly = false) => { - if (!warned.has(message)) { - if (process.env.NODE_ENV === 'development') { - console.warn(message); - } else if (!developmentOnly) { - // In production, use console.error for better visibility - console.error(message); - } - warned.add(message); - } -}; \ No newline at end of file From fb0b7cd23f6c705f4fcaabeae55142c0d4439c77 Mon Sep 17 00:00:00 2001 From: Mark Lundin Date: Wed, 2 Apr 2025 15:20:39 +0100 Subject: [PATCH 2/5] Refactor Anim component props and clean up validation utility - Updated AnimProps to extend PublicProps for AnimComponent, enhancing type safety. - Removed commented-out validateAndSanitize function from validation.ts for cleaner code. --- packages/lib/src/components/Anim.tsx | 4 ++-- packages/lib/src/utils/validation.ts | 19 ------------------- 2 files changed, 2 insertions(+), 21 deletions(-) diff --git a/packages/lib/src/components/Anim.tsx b/packages/lib/src/components/Anim.tsx index 883ff1ba..7637361d 100644 --- a/packages/lib/src/components/Anim.tsx +++ b/packages/lib/src/components/Anim.tsx @@ -2,12 +2,12 @@ import { FC, useLayoutEffect } from "react"; import { useComponent, useParent } from "../hooks"; -import { AnimComponent, Asset, Entity, LightComponent } from "playcanvas"; +import { AnimComponent, Asset, Entity } from "playcanvas"; import { PublicProps } from "../utils/types-utils"; import { WithCssColors } from "../utils/color"; -interface AnimProps extends Partial>> { +interface AnimProps extends Partial>> { asset : Asset } diff --git a/packages/lib/src/utils/validation.ts b/packages/lib/src/utils/validation.ts index 073cfdf7..261c35cf 100644 --- a/packages/lib/src/utils/validation.ts +++ b/packages/lib/src/utils/validation.ts @@ -12,25 +12,6 @@ export const warnOnce = (message: string, developmentOnly = false) => { } }; -// export function validateAndSanitize( -// props: T, -// key: K, -// validator: (value: any) => boolean, -// defaultValue: any, -// errorMessage?: string -// ): T[K] { -// const value = props[key]; -// const isValid = validator(value); - -// // Log warning in development if invalid -// if (!isValid && process.env.NODE_ENV !== 'production' && errorMessage) { -// console.warn(errorMessage); -// } - -// // Return original value if valid, default if not -// return isValid ? value : defaultValue; -// } - export type PropSchemaDefinition = { validate: (value: unknown) => boolean; errorMsg: (value: unknown) => string; From 6f8b02bbb07f82ff086bda7ebfa8a18e59ddc5f7 Mon Sep 17 00:00:00 2001 From: Mark Lundin Date: Thu, 3 Apr 2025 14:29:44 +0100 Subject: [PATCH 3/5] Enhance component documentation across multiple files - Updated JSDoc comments for Anim, Camera, Collision, GSplat, Light, Render, Script, Sprite, useApp, useMaterial, and useParent components to include parameter descriptions, return types, and usage examples. - Improved clarity and consistency in documentation to aid developers in understanding component usage. --- packages/lib/src/Application.tsx | 129 +++++++++++------ packages/lib/src/Entity.tsx | 149 +++++++++----------- packages/lib/src/components/Anim.tsx | 34 +++-- packages/lib/src/components/Camera.tsx | 30 +++- packages/lib/src/components/Collision.tsx | 71 +++++++--- packages/lib/src/components/GSplat.tsx | 53 ++++++- packages/lib/src/components/Light.tsx | 35 +++-- packages/lib/src/components/Render.tsx | 16 ++- packages/lib/src/components/RigidBody.tsx | 41 ++++-- packages/lib/src/components/Script.tsx | 48 +++++-- packages/lib/src/components/Sprite.tsx | 38 ++++- packages/lib/src/hooks/use-app.tsx | 12 +- packages/lib/src/hooks/use-component.tsx | 4 +- packages/lib/src/hooks/use-material.tsx | 46 ++++-- packages/lib/src/hooks/use-parent.tsx | 7 + packages/lib/src/hooks/use-script.tsx | 37 +++-- packages/lib/src/utils/color.ts | 2 +- packages/lib/src/utils/validation.ts | 163 +++++++++++++++++++--- tsconfig.json | 3 +- 19 files changed, 656 insertions(+), 262 deletions(-) diff --git a/packages/lib/src/Application.tsx b/packages/lib/src/Application.tsx index 62de6eb9..139db4c5 100644 --- a/packages/lib/src/Application.tsx +++ b/packages/lib/src/Application.tsx @@ -1,4 +1,4 @@ -import React, { FC, /*PropsWithChildren,*/ useLayoutEffect, useMemo, useRef, useState } from 'react'; +import React, { FC, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { FILLMODE_NONE, FILLMODE_FILL_WINDOW, @@ -9,45 +9,26 @@ import { TouchDevice, Entity as PcEntity, RESOLUTION_FIXED, - GraphicsDevice, + type GraphicsDevice, } from 'playcanvas'; import { AppContext, ParentContext } from './hooks'; import { PointerEventsContext } from './contexts/pointer-events-context'; import { usePicker } from './utils/picker'; import { PhysicsProvider } from './contexts/physics-context'; +import { validateAndSanitizeProps, createSchema } from './utils/validation'; import { PublicProps } from './utils/types-utils'; -type GraphicsOptions = Partial> - -interface ApplicationProps extends Partial> { - /** The class name to attach to the canvas component */ - className?: string, - /** A style object added to the canvas component */ - style?: Record - /** Controls how the canvas fills the window and resizes when the window changes. */ - 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. */ - resolutionMode?: typeof RESOLUTION_AUTO | typeof RESOLUTION_FIXED - // /** Clamps per-frame delta time to an upper bound. Useful since returning from a tab deactivation can generate huge values for dt, which can adversely affect game state. */ - // maxDeltaTime?: number - // /** Scales the global time delta. */ - // timeScale?: number, - /** Whether to use the PlayCanvas Physics system. */ - usePhysics?: boolean, - /** Graphics Settings */ - graphicsDeviceOptions?: GraphicsOptions, - /** The children of the application */ - children?: React.ReactNode, -} - - -interface ApplicationWithoutCanvasProps extends ApplicationProps { - /** A ref to a html canvas element */ - canvasRef: React.RefObject; -} - /** - * The **Application** component is the root node of the PlayCanvas React api. It creates a canvas element + * 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, @@ -73,18 +54,37 @@ export const Application: React.FC = ({ /** * An alternative Application component that does not create a canvas element. - * This allows you to create a canvas independently from Playcanvas and pass this in as a ref. + * 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 ( + * <> + * + * + * + * + * + * ); */ -export const ApplicationWithoutCanvas: FC = ({ - children, - canvasRef, - fillMode = FILLMODE_NONE, - resolutionMode = RESOLUTION_AUTO, - maxDeltaTime = 0.1, - timeScale = 1, - usePhysics = false, - ...otherProps -}) => { +export const ApplicationWithoutCanvas: FC = (props) => { + + const validatedProps = validateAndSanitizeProps(props, schema, 'Application'); + + const { + children, + canvasRef, + fillMode = FILLMODE_NONE, + resolutionMode = RESOLUTION_AUTO, + maxDeltaTime = 0.1, + timeScale = 1, + usePhysics = false, + ...otherProps + } = validatedProps; const graphicsDeviceOptions = { alpha: true, @@ -151,4 +151,45 @@ export const ApplicationWithoutCanvas: FC = ({ ); -}; \ No newline at end of file +}; + +type GraphicsOptions = Partial> + +interface ApplicationProps extends Partial> { + /** The class name to attach to the canvas component */ + className?: string, + /** A style object added to the canvas component */ + style?: Record + /** Controls how the canvas fills the window and resizes when the window changes. */ + 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. */ + resolutionMode?: typeof RESOLUTION_AUTO | typeof RESOLUTION_FIXED + // /** Clamps per-frame delta time to an upper bound. Useful since returning from a tab deactivation can generate huge values for dt, which can adversely affect game state. */ + maxDeltaTime?: number + // /** Scales the global time delta. */ + timeScale?: number, + /** Whether to use the PlayCanvas Physics system. */ + usePhysics?: boolean, + /** Graphics Settings */ + graphicsDeviceOptions?: GraphicsOptions, + /** The children of the application */ + children?: React.ReactNode, +} + + +interface ApplicationWithoutCanvasProps extends ApplicationProps { + /** A ref to a html canvas element */ + canvasRef: React.RefObject; +} + +const schema = { + ...createSchema( + () => new PlayCanvasApplication(document.createElement('canvas')), + (app) => app.destroy() + ), + usePhysics: { + validate: (value: unknown) => typeof value === 'boolean', + errorMsg: (value: unknown) => `usePhysics must be a boolean. Received: ${value}`, + default: false + } +} \ No newline at end of file diff --git a/packages/lib/src/Entity.tsx b/packages/lib/src/Entity.tsx index e46a3339..690f106c 100644 --- a/packages/lib/src/Entity.tsx +++ b/packages/lib/src/Entity.tsx @@ -6,23 +6,7 @@ import { useParent, ParentContext, useApp } from './hooks'; import { SyntheticMouseEvent, SyntheticPointerEvent } from './utils/synthetic-event'; import { usePointerEventsContext } from './contexts/pointer-events-context'; import { PublicProps } from './utils/types-utils'; -import { PropSchemaDefinition, validateAndSanitizeProps } from './utils/validation'; - -type PointerEventCallback = (event: SyntheticPointerEvent) => void; -type MouseEventCallback = (event: SyntheticMouseEvent) => void; - -export interface EntityProps extends Partial> { - name?: string; - position?: [number, number, number]; - scale?: [number, number, number]; - rotation?: [number, number, number, number?]; - onPointerUp?: PointerEventCallback; - onPointerDown?: PointerEventCallback; - onPointerOver?: PointerEventCallback; - onPointerOut?: PointerEventCallback; - onClick?: MouseEventCallback; - children?: ReactNode; -} +import { createSchema, validateAndSanitizeProps } from './utils/validation'; /** * The Entity component is the fundamental building block of a PlayCanvas scene. @@ -34,77 +18,24 @@ export interface EntityProps extends Partial> { * * * - * @param {Object} props - Component props - * @param {string} [props.name="Untitled"] - The name of the entity - * @param {number[] | Vec3} [props.position=[0,0,0]] - The local position - * @param {number[] | Vec3} [props.scale=[1,1,1]] - The local scale - * @param {number[] | Quat} [props.rotation=[0,0,0,1]] - The local rotation - * @param {boolean} [props.enabled=true] - Whether the entity is enabled - * @param {function} [props.onPointerDown] - Pointer down event handler - * @param {function} [props.onPointerUp] - Pointer up event handler - * @param {function} [props.onPointerOver] - Pointer over event handler - * @param {function} [props.onPointerOut] - Pointer out event handler - * @param {function} [props.onClick] - Click event handler - * @param {React.ReactNode} [props.children] - Child components - * @param {React.Ref} ref - Ref to access the underlying PlayCanvas Entity - * @returns {React.ReactElement} The Entity component + * @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 schema: { - [K in keyof EntityProps]?: PropSchemaDefinition - } = { - name: { - validate: (val: unknown) => typeof val === 'string', - errorMsg: (val: unknown) => `Invalid "name" prop: expected a string, got ${typeof val}`, - default: 'Untitled' - }, - position: { - validate: (val: unknown) => Array.isArray(val) && val.length === 3, - errorMsg: (val: unknown) => `Invalid "position" prop: expected an array of 3 numbers, got ${typeof val}`, - default: [0, 0, 0], - }, - rotation: { - validate: (val: unknown) => Array.isArray(val) && (val.length === 3 || val.length === 4), - errorMsg: (val: unknown) => `Invalid "rotation" prop: expected an array of 3 or 4 numbers, got ${typeof val}`, - default: [0, 0, 0, 1] - }, - scale: { - validate: (val: unknown) => Array.isArray(val) && val.length === 3, - errorMsg: (val: unknown) => `Invalid "scale" prop: expected array of 3 numbers, got ${typeof val}`, - default: [1, 1, 1] - }, - onPointerDown: { - validate: (val: unknown) => typeof val === 'function', - errorMsg: (val: unknown) => `Invalid "onPointerDown" prop: expected a function, got ${typeof val}`, - default: undefined - }, - onPointerUp: { - validate: (val: unknown) => typeof val === 'function', - errorMsg: (val: unknown) => `Invalid "onPointerUp" prop: expected a function, got ${typeof val}`, - default: undefined - }, - onPointerOver: { - validate: (val: unknown) => typeof val === 'function', - errorMsg: (val: unknown) => `Invalid "onPointerOver" prop: expected a function, got ${typeof val}`, - default: undefined - }, - onPointerOut: { - validate: (val: unknown) => typeof val === 'function', - errorMsg: (val: unknown) => `Invalid "onPointerOut" prop: expected a function, got ${typeof val}`, - default: undefined - }, - onClick: { - validate: (val: unknown) => typeof val === 'function', - errorMsg: (val: unknown) => `Invalid "onClick" prop: expected a function, got ${typeof val}`, - default: undefined - }, - }; - - const safeProps = validateAndSanitizeProps(props, schema); + const safeProps = validateAndSanitizeProps(props as Record, schema, 'Entity'); const { /** The name of the entity */ @@ -127,7 +58,7 @@ export const Entity = forwardRef (function Entity( onPointerOut, /** The callback for the click event */ onClick, - } = safeProps; + } : EntityProps = safeProps; const parent = useParent(); const app = useApp(); @@ -137,7 +68,7 @@ export const Entity = forwardRef (function Entity( const hasPointerEvents = !!(onPointerDown || onPointerUp || onPointerOver || onPointerOut || onClick); // Create the entity only when 'app' changes - const entity = useMemo(() => new PcEntity(name, app), [app]) as PcEntity + const entity = useMemo(() => new PcEntity(undefined, app), [app]) as PcEntity useImperativeHandle(ref, () => entity); @@ -190,4 +121,52 @@ export const Entity = forwardRef (function Entity( {children || null} ); -}); \ No newline at end of file +}); + +type PointerEventCallback = (event: SyntheticPointerEvent) => void; +type MouseEventCallback = (event: SyntheticMouseEvent) => void; + +export interface EntityProps extends Partial> { + name?: string; + position?: [number, number, number]; + scale?: [number, number, number]; + rotation?: [number, number, number, number?]; + onPointerUp?: PointerEventCallback; + onPointerDown?: PointerEventCallback; + onPointerOver?: PointerEventCallback; + onPointerOut?: PointerEventCallback; + onClick?: MouseEventCallback; + children?: ReactNode; +} + +const schema = { + ...createSchema( + () => new PcEntity(), + (entity) => entity.destroy() + ), + 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/Anim.tsx b/packages/lib/src/components/Anim.tsx index 7637361d..72d09ed1 100644 --- a/packages/lib/src/components/Anim.tsx +++ b/packages/lib/src/components/Anim.tsx @@ -5,19 +5,27 @@ import { useComponent, useParent } from "../hooks"; import { AnimComponent, Asset, Entity } from "playcanvas"; import { PublicProps } from "../utils/types-utils"; import { WithCssColors } from "../utils/color"; - - -interface AnimProps extends Partial>> { - asset : Asset -} +import { createSchema, validateAndSanitizeProps } from "../utils/validation"; /** - * The Camera component is used to define the position and properties of a camera entity. + * 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. + * + * @example + * + * + * + * */ export const Anim: FC = ({ asset, ...props }) => { + const safeProps = validateAndSanitizeProps(props as Record, schema, 'Anim'); + // Create the anim component - useComponent("anim", props); + useComponent("anim", safeProps as Partial); // Get the associated Entity const entity : Entity = useParent(); @@ -40,4 +48,14 @@ export const Anim: FC = ({ asset, ...props }) => { }, [asset?.id, entity.getGuid()]) return null; -} \ No newline at end of file +} + +interface AnimProps extends Partial>> { + asset : Asset +} + + +const schema = createSchema( + () => new Entity().addComponent('anim') as AnimComponent, + (component) => (component as AnimComponent).system.destroy() +) \ No newline at end of file diff --git a/packages/lib/src/components/Camera.tsx b/packages/lib/src/components/Camera.tsx index 5b6cd441..aac12e23 100644 --- a/packages/lib/src/components/Camera.tsx +++ b/packages/lib/src/components/Camera.tsx @@ -2,20 +2,36 @@ import { FC } from "react"; import { useComponent } from "../hooks"; -import { CameraComponent } from "playcanvas"; +import { CameraComponent, Entity } from "playcanvas"; import { useColors, WithCssColors } from "../utils/color"; import { PublicProps } from "../utils/types-utils"; - -type CameraProps = Partial>>; +import { createSchema, validateAndSanitizeProps } from "../utils/validation"; /** - * The Camera component is used to define the position and properties of a camera entity. + * 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 colorProps = useColors(props, ['clearColor']) + const safeProps = validateAndSanitizeProps(props as Record, schema, 'Camera'); + + const colorProps = useColors(safeProps, ['clearColor']) - useComponent("camera", { ...props, ...colorProps }); + useComponent("camera", { ...safeProps, ...colorProps }); return null; -} \ No newline at end of file +} + +type CameraProps = Partial>>; + +const schema = createSchema( + () => new Entity().addComponent('camera') as CameraComponent, + (component) => (component as CameraComponent).system.destroy() +) \ No newline at end of file diff --git a/packages/lib/src/components/Collision.tsx b/packages/lib/src/components/Collision.tsx index 9272dc13..ed7f1e39 100644 --- a/packages/lib/src/components/Collision.tsx +++ b/packages/lib/src/components/Collision.tsx @@ -3,38 +3,54 @@ import { FC, useEffect } from "react"; import { useComponent, useParent } from "../hooks"; import { usePhysics } from "../contexts/physics-context"; -import { warnOnce } from "../utils/validation"; -import { CollisionComponent } from "playcanvas"; +import { createSchema, validateAndSanitizeProps, warnOnce } from "../utils/validation"; +import { CollisionComponent, Entity } from "playcanvas"; import { PublicProps } from "../utils/types-utils"; -interface CollisionProps extends Partial> { - type?: "box" - | "capsule" - | "compound" - | "cone" - | "cylinder" - | "mesh" - | "sphere" -} - +/** + * 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. + * + * @example + * + * + * // will infer the shape from the render component + * + */ export const Collision: FC = (props) => { + + const safeProps = validateAndSanitizeProps(props as Record, schema, 'Collision'); + const entity = useParent(); const { isPhysicsEnabled, isPhysicsLoaded, physicsError } = usePhysics(); useEffect(() => { if (!isPhysicsEnabled) { warnOnce( - 'The `` component requires `usePhysics` to be set on the Application. ' + - 'Please add `` to your root component.', - false // Show in both dev and prod + '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://playcanvas-react.vercel.app/docs/physics' ); } if (physicsError) { warnOnce( - `Failed to initialize physics: ${physicsError.message}. ` + - "Run `npm install sync-ammo` in your project, if you haven't done so already.", - false // Show in both dev and prod + `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://playcanvas-react.vercel.app/docs/physics#troubleshooting' ); } }, [isPhysicsEnabled, physicsError]); @@ -43,7 +59,22 @@ 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, { ...props, type }); + useComponent(isPhysicsLoaded ? "collision" : null, { ...safeProps, type }); return null; -} \ No newline at end of file +} + +interface CollisionProps extends Partial> { + type?: "box" + | "capsule" + | "compound" + | "cone" + | "cylinder" + | "mesh" + | "sphere" +} + +const schema = createSchema( + () => new Entity().addComponent('collision') as CollisionComponent, + (component) => (component as CollisionComponent).system.destroy() +) \ No newline at end of file diff --git a/packages/lib/src/components/GSplat.tsx b/packages/lib/src/components/GSplat.tsx index e315e926..91de3fa3 100644 --- a/packages/lib/src/components/GSplat.tsx +++ b/packages/lib/src/components/GSplat.tsx @@ -4,14 +4,21 @@ import { FC, useLayoutEffect, useRef } from "react"; import { useParent } from "../hooks"; import { Asset, Entity, GSplatComponent } from "playcanvas"; import { PublicProps } from "../utils/types-utils"; +import { createSchema, validateAndSanitizeProps } from "../utils/validation"; -interface GSplatProps extends Partial> { - asset: Asset; - vertex?: string; - fragment?: string; -} +/** + * The GSplat component allows an entity to render a Gaussian Splat. + * @param {GSplatProps} props - The props to pass to the GSplat component. + * + * @example + * const { data: splat } = useSplat('./splat.ply') + * + */ +export const GSplat: FC = (props) => { + + const safeProps = validateAndSanitizeProps(props, schema, 'GSplat'); -export const GSplat: FC = ({ vertex, fragment, asset }) => { + const { asset, vertex, fragment } = safeProps; const parent: Entity = useParent(); const assetRef = useRef(null); @@ -28,4 +35,36 @@ export const GSplat: FC = ({ vertex, fragment, asset }) => { }, [asset]); return null; -}; \ No newline at end of file +}; + +interface GSplatProps extends Partial> { + /** + * The asset to use for the GSplat. + */ + asset: Asset; + /** + * The vertex shader to use for the GSplat. + */ + vertex?: string; + /** + * The fragment shader to use for the GSplat. + */ + fragment?: string; +} + +const schema = { + ...createSchema( + () => new Entity().addComponent('gsplat') as GSplatComponent, + (component) => (component as GSplatComponent).system.destroy() + ), + vertex: { + validate: (value: unknown) => typeof value === 'string', + errorMsg: (value: unknown) => `Vertex shader must be a string, received ${value}`, + default: null // Allows engine to handle the default shader + }, + fragment: { + validate: (value: unknown) => typeof value === 'string', + errorMsg: (value: unknown) => `Fragment shader must be a string, received ${value}`, + default: null // Allows engine to handle the default shader + } +} \ No newline at end of file diff --git a/packages/lib/src/components/Light.tsx b/packages/lib/src/components/Light.tsx index 71777fa5..83bd26cc 100644 --- a/packages/lib/src/components/Light.tsx +++ b/packages/lib/src/components/Light.tsx @@ -1,18 +1,35 @@ import { FC } from "react"; import { useComponent } from "../hooks"; import { useColors, WithCssColors } from "../utils/color"; -import { LightComponent } from "playcanvas"; +import { Entity, LightComponent } from "playcanvas"; import { PublicProps } from "../utils/types-utils"; +import { createSchema, validateAndSanitizeProps } from "../utils/validation"; -interface LightProps extends Partial>> { - type: "directional" | "omni" | "spot"; -} - +/** + * 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. + * + * @example + * + * + * + */ export const Light: FC = (props) => { - const colorProps = useColors(props, ['color']) + const safeProps = validateAndSanitizeProps(props as Partial, schema, 'Light'); + const colorProps = useColors(safeProps, ['color']) - useComponent("light", { ...props, ...colorProps }); + useComponent("light", { ...safeProps, ...colorProps }); return null - -} \ No newline at end of file + +} + +interface LightProps extends Partial>> { + type: "directional" | "omni" | "spot"; +} + +const schema = createSchema( + () => new Entity().addComponent('light') as LightComponent, + (component) => (component as LightComponent).system.destroy() +) \ No newline at end of file diff --git a/packages/lib/src/components/Render.tsx b/packages/lib/src/components/Render.tsx index c89c2522..5e3c9196 100644 --- a/packages/lib/src/components/Render.tsx +++ b/packages/lib/src/components/Render.tsx @@ -23,9 +23,19 @@ const RenderComponent: FC = (props) => { } /** - * Create a render component on an entity. If the asset is a container, - * it will be rendered as a container. Otherwise, it will be rendered as a - * render component. + * 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. + * + * @example + * const { data: asset } = useAsset('./statue.glb') + * + * + * + * + * + * */ export const Render: FC = (props : RenderProps) => { diff --git a/packages/lib/src/components/RigidBody.tsx b/packages/lib/src/components/RigidBody.tsx index bd7b4d7a..98a79730 100644 --- a/packages/lib/src/components/RigidBody.tsx +++ b/packages/lib/src/components/RigidBody.tsx @@ -15,6 +15,18 @@ interface RigidBodyProps extends Partial> { | "sphere" } +/** + * Adding a RigidBody component to an entity allows it to participate in the physics simulation. + * Rigid bodies have mass, and can be moved around by forces. Ensure `usePhysics` is set on the Application + * to use this component. + * + * @param {RigidBodyProps} props - The props to pass to the rigid body component. + * + * @example + * + * + * + */ export const RigidBody: FC = (props) => { const entity = useParent(); const { isPhysicsEnabled, isPhysicsLoaded, physicsError } = usePhysics(); @@ -22,26 +34,33 @@ export const RigidBody: FC = (props) => { useEffect(() => { if (!isPhysicsEnabled) { warnOnce( - 'The `` component requires `usePhysics` to be set on the Application. ' + - 'Please add `` to your root component.', - false // Show in both dev and prod + '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://playcanvas-react.vercel.app/docs/physics' ); } if (physicsError) { warnOnce( - `Failed to initialize physics: ${physicsError.message}. ` + - "Run `npm install sync-ammo` in your project, if you haven't done so already.", - false // Show in both dev and prod + `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://playcanvas-react.vercel.app/docs/physics#troubleshooting' ); } }, [isPhysicsEnabled, physicsError]); - // @ts-expect-error Ammo is defined in the global scope in the browser - if(isPhysicsLoaded && !globalThis.Ammo ) { - throw new Error('The `` component requires `usePhysics` to be set on the Application. `` ') - } - // If no type is defined, infer if possible from a render component const type = entity.render && props.type === undefined ? entity.render.type : props.type; diff --git a/packages/lib/src/components/Script.tsx b/packages/lib/src/components/Script.tsx index e9889bba..4106d54c 100644 --- a/packages/lib/src/components/Script.tsx +++ b/packages/lib/src/components/Script.tsx @@ -2,21 +2,53 @@ import { AppBase, Entity, Script as PcScript } from "playcanvas"; import { useScript } from "../hooks" import { FC, memo, useMemo } from "react"; import { shallowEquals } from "../utils/shallow-equals"; +import { validateAndSanitizeProps } from "../utils/validation"; -interface ScriptProps { - script: new (args: { app: AppBase; entity: Entity; }) => PcScript; - [key: string]: unknown; -} +/** + * The Script component allows you to hook into the entity's lifecycle. This allows you to + * run code during the frame update loop, or when the entity is created or destroyed. + * Use this for high-performance code that needs to run on every frame. + * + * @param {ScriptProps} props - The props to pass to the script component. + * + * @example + * // A Rotator script that rotates the entity around the Y axis + * class Rotator extends Script { + * update(dt: number) { + * this.entity.rotate(0, 1, 0, dt); + * } + * } + *