diff --git a/docs/property-matrix.md b/docs/property-matrix.md new file mode 100644 index 0000000..2959f43 --- /dev/null +++ b/docs/property-matrix.md @@ -0,0 +1,133 @@ +# Two.js Property Matrix & Declarative Coverage + +This document outlines the declarative prop coverage for `react-two.js` components against **Two.js v0.8.23**. + +## Overview + +`react-two.js` exposes Two.js scenegraph objects as declarative React components. All scene-relevant, writable Two.js properties are exposed as React props with their upstream Two.js types. + +Where ergonomic improvements are possible without breaking Two.js parity (for example, passing `{ x, y }` or `[x, y]` to `Rectangle.origin`, or passing a `Two.Texture` directly to `Sprite.src`), these are supported alongside upstream types. + +--- + +## Property Matrix Table + +| Component | Upstream Two.js Class | Supported Declarative Props | Key Additions in v0.8.23-r.2 | +|---|---|---|---| +| `` | `Two.Group` | `id`, `className`, `position`, `translation`, `rotation`, `scale`, `skewX`, `skewY`, `matrix`, `fill`, `stroke`, `linewidth`, `cap`, `join`, `miter`, `closed`, `curved`, `automatic`, `opacity`, `visible`, `mask`, `beginning`, `ending`, `strokeAttenuation`, `x`, `y` | `mask`, `beginning`, `ending`, `strokeAttenuation` | +| `` | `Two.Group` | All `` props, plus `src`, `content`, `shallow`, `onLoad`, `onError` | `opacity`, `visible`, `mask`, `beginning`, `ending`, `strokeAttenuation` | +| `` | `Two.Path` | `id`, `className`, `position`, `translation`, `rotation`, `scale`, `skewX`, `skewY`, `matrix`, `fill`, `stroke`, `linewidth`, `opacity`, `visible`, `cap`, `join`, `miter`, `closed`, `curved`, `automatic`, `beginning`, `ending`, `dashes`, `vertices`, `mask`, `clip`, `strokeAttenuation`, `manual`, `x`, `y` | `mask`, `clip`, `strokeAttenuation` | +| `` | `Two.Rectangle` | All `` props, plus `width`, `height`, `origin` | `origin`, `mask`, `clip`, `strokeAttenuation` | +| `` | `Two.Circle` | All `` props, plus `radius`, `resolution` | `mask`, `clip`, `strokeAttenuation` | +| `` | `Two.Ellipse` | All `` props, plus `width`, `height`, `resolution` | `mask`, `clip`, `strokeAttenuation` | +| `` | `Two.Line` | All `` props, plus `left`, `right`, `x1`, `y1`, `x2`, `y2` | `mask`, `clip`, `strokeAttenuation` | +| `` | `Two.Polygon` | All `` props, plus `width`, `height`, `sides`, `radius` | `radius`, `mask`, `clip`, `strokeAttenuation` | +| `` | `Two.RoundedRectangle` | All `` props, plus `width`, `height`, `radius` | `mask`, `clip`, `strokeAttenuation` | +| `` | `Two.Star` | All `` props, plus `innerRadius`, `outerRadius`, `sides` | `mask`, `clip`, `strokeAttenuation` | +| `` | `Two.ArcSegment` | All `` props, plus `startAngle`, `endAngle`, `innerRadius`, `outerRadius`, `resolution` | `mask`, `clip`, `strokeAttenuation` | +| `` | `Two.Points` | `id`, `className`, `position`, `translation`, `rotation`, `scale`, `skewX`, `skewY`, `matrix`, `fill`, `stroke`, `linewidth`, `opacity`, `visible`, `size`, `sizeAttenuation`, `beginning`, `ending`, `dashes`, `vertices`, `strokeAttenuation`, `x`, `y` | `strokeAttenuation` | +| `` | `Two.Text` | `id`, `className`, `position`, `translation`, `rotation`, `scale`, `skewX`, `skewY`, `matrix`, `value`, `family`, `size`, `leading`, `alignment`, `linewidth`, `style`, `weight`, `decoration`, `direction`, `baseline`, `opacity`, `visible`, `fill`, `stroke`, `dashes`, `mask`, `clip`, `strokeAttenuation`, `x`, `y` | `mask`, `clip`, `strokeAttenuation` | +| `` | `Two.Image` | All `` props, plus `texture`, `mode`, `src` | `origin`, `mask`, `clip`, `strokeAttenuation` | +| `` | `Two.Sprite` | All `` props, plus `texture`, `columns`, `rows`, `frameRate`, `index`, `firstFrame`, `lastFrame`, `loop`, `src`, `autoPlay` | `origin`, `Two.Texture` as `src`, `mask`, `clip`, `strokeAttenuation` | +| `` | `Two.ImageSequence` | All `` props, plus `textures`, `frameRate`, `index`, `firstFrame`, `lastFrame`, `loop`, `src`, `autoPlay` | `origin`, `mask`, `clip`, `strokeAttenuation` | +| `` | `Two.LinearGradient` | `id`, `className`, `spread`, `units`, `stops`, `left`, `right`, `x1`, `y1`, `x2`, `y2` | Full declarative parity | +| `` | `Two.RadialGradient` | `id`, `className`, `spread`, `units`, `stops`, `center`, `radius`, `focal`, `x`, `y`, `focalX`, `focalY` | Full declarative parity | +| `` | `Two.Texture` | `id`, `className`, `src`, `loaded`, `repeat`, `scale`, `offset`, `image` | Full declarative parity | + +--- + +## Detailed Prop Support + +### 1. `mask` and `clip` +- Supported on ``, ``, ``, and all Path-derived shapes (``, ``, ``, etc.). +- Direct declarative assignment: + ```tsx + const maskRef = useRef(null); + + + ``` +- `clip` (`boolean`) can be set declaratively to indicate that an element acts as a clipping mask. + +### 2. `strokeAttenuation` +- Supported on ``, ``, ``, ``, and all Path-derived shapes. +- When `true` (default), stroke width scales with matrix transformations. +- When `false`, stroke width remains constant in screen space regardless of zoom level or scaling: + ```tsx + + ``` + +### 3. Rectangle `origin` +- Supported on ``, ``, ``, and ``. +- Accepts upstream `Two.Vector`, as well as convenient object literals `{ x?: number; y?: number }` and tuples `[number, number]`: + ```tsx + // With Two.Vector + + + // With object literal + + + // With tuple + + ``` + +### 4. `Two.Texture` as `Sprite.src` +- `` accepts either a URL string or a `Two.Texture` instance: + ```tsx + const texture = new Two.Texture('/assets/spritesheet.png'); + + ``` + +--- + +## Intentional Omissions & Imperative Escape Hatch + +Certain properties from Two.js instances are intentionally omitted from declarative prop interfaces. Read-only and internal properties should not be exposed merely because they exist on an instance. + +### Categories of Omissions + +1. **Computed & Read-Only Properties**: + - `length` (on `Path`, `Group`, `Points`): Computed dynamically by Two.js. Setting it imperatively is a no-op or causes desynchronization. Read via `ref.current.length`. + - `worldMatrix` (on all `Shape`s): Derived matrix calculated from the scene graph hierarchy. Read via `ref.current.worldMatrix`. + +2. **Renderer-Internal State**: + - `renderer` (on `Element` / `Shape`): Contains low-level renderer state and DOM elements (e.g. `renderer.elem` for SVG nodes). Access via `ref.current.renderer`. + - `additions`, `subtractions` (on `Group`): Internal Two.js renderer tracking queues. + +3. **React-Managed Tree Hierarchies**: + - `children` (on `Group`): Managed through React JSX children composition rather than direct array assignment. + - `parent`: Managed by React Context (`TwoParentContext`) and component mount/unmount lifecycle. + +4. **Imperative Methods**: + - `play()`, `pause()`, `stop()` on `Sprite` / `ImageSequence`: Handled declaratively via the `autoPlay` boolean prop, or imperatively via `ref.current.play()`. + - `clone()`, `copy()`: Scenegraph duplication methods. + - `subdivide()`, `plot()`: Imperative algorithmic routines. + +### The Imperative Escape Hatch + +For any omitted properties, renderer-specific adjustments, or high-frequency animations, use the forwarded ref to access the underlying Two.js instance: + +```tsx +import { useRef, useEffect } from 'react'; +import { Canvas, Rectangle, RefRectangle, useFrame } from 'react-two.js'; + +function MyShape() { + const rectRef = useRef(null); + + useEffect(() => { + if (rectRef.current) { + // Access underlying properties or renderer-specific state + console.log('Arc length:', rectRef.current.length); + console.log('Computed world matrix:', rectRef.current.worldMatrix); + console.log('SVG Element:', rectRef.current.renderer?.elem); + } + }, []); + + useFrame(() => { + if (rectRef.current) { + rectRef.current.rotation += 0.01; + } + }); + + return ; +} +``` diff --git a/lib/ArcSegment.tsx b/lib/ArcSegment.tsx index cb9f411..4469366 100644 --- a/lib/ArcSegment.tsx +++ b/lib/ArcSegment.tsx @@ -7,7 +7,7 @@ import { PathProps } from './Path'; import { type EventHandlers } from './Properties'; import { EVENT_HANDLER_NAMES } from './Events'; -type ArcSegmentProps = +export type ArcSegmentProps = | PathProps | 'startAngle' | 'endAngle' diff --git a/lib/Circle.tsx b/lib/Circle.tsx index 8b48cb0..361f89e 100644 --- a/lib/Circle.tsx +++ b/lib/Circle.tsx @@ -7,7 +7,7 @@ import { PathProps } from './Path'; import { type EventHandlers } from './Properties'; import { EVENT_HANDLER_NAMES } from './Events'; -type CircleProps = PathProps | 'radius'; +export type CircleProps = PathProps | 'radius'; type ComponentProps = React.PropsWithChildren< { [K in Extract]?: Instance[K]; diff --git a/lib/Ellipse.tsx b/lib/Ellipse.tsx index c7757a3..2031a80 100644 --- a/lib/Ellipse.tsx +++ b/lib/Ellipse.tsx @@ -7,7 +7,7 @@ import { PathProps } from './Path'; import { type EventHandlers } from './Properties'; import { EVENT_HANDLER_NAMES } from './Events'; -type EllipseProps = PathProps | 'width' | 'height'; +export type EllipseProps = PathProps | 'width' | 'height'; type ComponentProps = React.PropsWithChildren< { [K in Extract]?: Instance[K]; diff --git a/lib/Group.tsx b/lib/Group.tsx index f2397a3..f00f2a6 100644 --- a/lib/Group.tsx +++ b/lib/Group.tsx @@ -6,7 +6,7 @@ import type { Group as Instance } from 'two.js/src/group'; import { ShapeProps, type EventHandlers } from './Properties'; import { EVENT_HANDLER_NAMES } from './Events'; -type GroupProps = +export type GroupProps = | ShapeProps | 'fill' | 'stroke' @@ -18,7 +18,11 @@ type GroupProps = | 'curved' | 'automatic' | 'opacity' - | 'visible'; + | 'visible' + | 'mask' + | 'beginning' + | 'ending' + | 'strokeAttenuation'; type ComponentProps = React.PropsWithChildren< { [K in Extract]?: Instance[K]; diff --git a/lib/Image.tsx b/lib/Image.tsx index 80c9880..61b75a3 100644 --- a/lib/Image.tsx +++ b/lib/Image.tsx @@ -5,17 +5,20 @@ import { useTwo } from './Context'; import type { Image as Instance } from 'two.js/src/effects/image'; import { RectangleProps } from './Rectangle'; import type { Texture } from 'two.js/src/effects/texture'; -import { type EventHandlers } from './Properties'; +import { applyOrigin, type EventHandlers, type OriginProp } from './Properties'; import { EVENT_HANDLER_NAMES } from './Events'; -type ImageProps = RectangleProps | 'mode' | 'texture'; +export type ImageProps = RectangleProps | 'mode' | 'texture'; type ComponentProps = React.PropsWithChildren< { - [K in Extract]?: Instance[K]; + [K in Extract]?: K extends 'origin' + ? OriginProp + : Instance[K]; } & { x?: number; y?: number; + origin?: OriginProp; mode?: string; src?: string | Texture; texture?: Texture; @@ -25,7 +28,7 @@ type ComponentProps = React.PropsWithChildren< export type RefImage = Instance; export const Image = React.forwardRef( - ({ mode, src, texture, x, y, ...props }, forwardedRef) => { + ({ mode, src, texture, x, y, origin, ...props }, forwardedRef) => { const { parent, registerEventShape, unregisterEventShape } = useTwo(); // Create the instance synchronously so it's available for refs immediately @@ -72,6 +75,9 @@ export const Image = React.forwardRef( if (typeof x === 'number') image.translation.x = x; if (typeof y === 'number') image.translation.y = y; + // Update origin + applyOrigin(image, origin); + // Update other properties (excluding event handlers) for (const key in shapeProps) { if (key in image) { @@ -79,7 +85,7 @@ export const Image = React.forwardRef( (image as any)[key] = (shapeProps as any)[key]; } } - }, [image, shapeProps, mode, texture, x, y]); + }, [image, shapeProps, mode, texture, x, y, origin]); // Unregister on unmount only useEffect(() => { diff --git a/lib/ImageSequence.tsx b/lib/ImageSequence.tsx index 8bb79c0..8ffad2a 100644 --- a/lib/ImageSequence.tsx +++ b/lib/ImageSequence.tsx @@ -5,10 +5,10 @@ import { useTwo } from './Context'; import type { ImageSequence as Instance } from 'two.js/src/effects/image-sequence'; import { RectangleProps } from './Rectangle'; import type { Texture } from 'two.js/src/effects/texture'; -import { type EventHandlers } from './Properties'; +import { applyOrigin, type EventHandlers, type OriginProp } from './Properties'; import { EVENT_HANDLER_NAMES } from './Events'; -type ImageSequenceProps = +export type ImageSequenceProps = | RectangleProps | 'textures' | 'frameRate' @@ -19,11 +19,14 @@ type ImageSequenceProps = type ComponentProps = React.PropsWithChildren< { - [K in Extract]?: Instance[K]; + [K in Extract]?: K extends 'origin' + ? OriginProp + : Instance[K]; } & { src?: string | string[] | Texture | Texture[]; x?: number; y?: number; + origin?: OriginProp; autoPlay?: boolean; } & Partial >; @@ -31,7 +34,7 @@ type ComponentProps = React.PropsWithChildren< export type RefImageSequence = Instance; export const ImageSequence = React.forwardRef( - ({ src, x, y, autoPlay, ...props }, forwardedRef) => { + ({ src, x, y, origin, autoPlay, ...props }, forwardedRef) => { const { parent, registerEventShape, unregisterEventShape } = useTwo(); // Create the instance synchronously so it's available for refs immediately @@ -81,6 +84,9 @@ export const ImageSequence = React.forwardRef( if (typeof x === 'number') imageSequence.translation.x = x; if (typeof y === 'number') imageSequence.translation.y = y; + // Update origin + applyOrigin(imageSequence, origin); + // Update other properties (excluding event handlers) for (const key in shapeProps) { if (key in imageSequence) { @@ -88,7 +94,7 @@ export const ImageSequence = React.forwardRef( (imageSequence as any)[key] = (shapeProps as any)[key]; } } - }, [shapeProps, imageSequence, x, y, autoPlay]); + }, [shapeProps, imageSequence, x, y, origin, autoPlay]); // Unregister on unmount only useEffect(() => { diff --git a/lib/Line.tsx b/lib/Line.tsx index cbdda2b..a93b2cc 100644 --- a/lib/Line.tsx +++ b/lib/Line.tsx @@ -7,7 +7,7 @@ import { PathProps } from './Path'; import { type EventHandlers } from './Properties'; import { EVENT_HANDLER_NAMES } from './Events'; -type LineProps = PathProps | 'left' | 'right'; +export type LineProps = PathProps | 'left' | 'right'; type ComponentProps = React.PropsWithChildren< { [K in Extract]?: Instance[K]; diff --git a/lib/LinearGradient.tsx b/lib/LinearGradient.tsx index 7b3c7c6..581db22 100644 --- a/lib/LinearGradient.tsx +++ b/lib/LinearGradient.tsx @@ -4,7 +4,7 @@ import Two from 'two.js'; import type { LinearGradient as Instance } from 'two.js/src/effects/linear-gradient'; import { GradientProps } from './Properties'; -type LinearGradientProps = GradientProps | 'left' | 'right'; +export type LinearGradientProps = GradientProps | 'left' | 'right'; type ComponentProps = React.PropsWithChildren< { diff --git a/lib/Path.tsx b/lib/Path.tsx index b0b6ef4..ff8e083 100644 --- a/lib/Path.tsx +++ b/lib/Path.tsx @@ -22,7 +22,10 @@ export type PathProps = | 'beginning' | 'ending' | 'dashes' - | 'vertices'; + | 'vertices' + | 'mask' + | 'clip' + | 'strokeAttenuation'; type ComponentProps = React.PropsWithChildren< { [K in Extract]?: Instance[K]; diff --git a/lib/Points.tsx b/lib/Points.tsx index 6324b03..cc992b2 100644 --- a/lib/Points.tsx +++ b/lib/Points.tsx @@ -6,7 +6,7 @@ import type { Points as Instance } from 'two.js/src/shapes/points'; import { ShapeProps, type EventHandlers } from './Properties'; import { EVENT_HANDLER_NAMES } from './Events'; -type PointsProps = +export type PointsProps = | ShapeProps | 'fill' | 'stroke' @@ -18,7 +18,8 @@ type PointsProps = | 'beginning' | 'ending' | 'dashes' - | 'vertices'; + | 'vertices' + | 'strokeAttenuation'; type ComponentProps = React.PropsWithChildren< { [K in Extract]?: Instance[K]; diff --git a/lib/Polygon.tsx b/lib/Polygon.tsx index de4bd89..293c337 100644 --- a/lib/Polygon.tsx +++ b/lib/Polygon.tsx @@ -7,7 +7,7 @@ import { PathProps } from './Path'; import { type EventHandlers } from './Properties'; import { EVENT_HANDLER_NAMES } from './Events'; -type PolygonProps = PathProps | 'width' | 'height' | 'sides'; +export type PolygonProps = PathProps | 'width' | 'height' | 'sides' | 'radius'; type ComponentProps = React.PropsWithChildren< { [K in Extract]?: Instance[K]; diff --git a/lib/Properties.ts b/lib/Properties.ts index bd17547..f7609b9 100644 --- a/lib/Properties.ts +++ b/lib/Properties.ts @@ -1,14 +1,65 @@ +import Two from 'two.js'; +import type { Vector } from 'two.js/src/vector'; + // Re-export event handler types for convenience export type { EventHandlers, EventHandler, TwoEvent } from './Events'; -export type ElementProps = 'renderer' | 'id' | 'className'; +export type ElementProps = 'id' | 'className'; export type ShapeProps = | ElementProps | 'position' + | 'translation' | 'rotation' | 'scale' | 'skewX' | 'skewY' - | 'matrix' - | 'worldMatrix'; + | 'matrix'; export type GradientProps = ElementProps | 'spread' | 'units' | 'stops'; + +export const ELEMENT_PROPERTIES = ['id', 'className'] as const; +export const SHAPE_PROPERTIES = [ + 'id', + 'className', + 'position', + 'translation', + 'rotation', + 'scale', + 'skewX', + 'skewY', + 'matrix', +] as const; +export const GRADIENT_PROPERTIES = [ + 'id', + 'className', + 'spread', + 'units', + 'stops', +] as const; + +export type OriginProp = + | Vector + | { x?: number; y?: number } + | readonly [number, number] + | [number, number]; + +/** + * Normalizes and applies origin coordinates onto a Two.js shape instance. + */ +export function applyOrigin( + instance: { origin: Vector }, + origin: OriginProp | undefined +): void { + if (typeof origin === 'undefined') return; + + if (origin instanceof Two.Vector) { + instance.origin = origin; + } else if (Array.isArray(origin) && origin.length >= 2) { + instance.origin.set(origin[0], origin[1]); + } else if (typeof origin === 'object' && origin !== null) { + const originObj = origin as { x?: number; y?: number }; + if (typeof originObj.x === 'number') instance.origin.x = originObj.x; + if (typeof originObj.y === 'number') instance.origin.y = originObj.y; + } +} + + diff --git a/lib/RadialGradient.tsx b/lib/RadialGradient.tsx index c06d0b0..b0b38ae 100644 --- a/lib/RadialGradient.tsx +++ b/lib/RadialGradient.tsx @@ -4,7 +4,7 @@ import Two from 'two.js'; import type { RadialGradient as Instance } from 'two.js/src/effects/radial-gradient'; import { GradientProps } from './Properties'; -type RadialGradientProps = GradientProps | 'center' | 'radius' | 'focal'; +export type RadialGradientProps = GradientProps | 'center' | 'radius' | 'focal'; type ComponentProps = React.PropsWithChildren< { diff --git a/lib/Rectangle.tsx b/lib/Rectangle.tsx index 77aadae..71301f5 100644 --- a/lib/Rectangle.tsx +++ b/lib/Rectangle.tsx @@ -4,23 +4,26 @@ import { useTwo } from './Context'; import type { Rectangle as Instance } from 'two.js/src/shapes/rectangle'; import { PathProps } from './Path'; -import { type EventHandlers } from './Properties'; +import { applyOrigin, type EventHandlers, type OriginProp } from './Properties'; import { EVENT_HANDLER_NAMES } from './Events'; -export type RectangleProps = PathProps | 'width' | 'height'; +export type RectangleProps = PathProps | 'width' | 'height' | 'origin'; type ComponentProps = React.PropsWithChildren< { - [K in Extract]?: Instance[K]; + [K in Extract]?: K extends 'origin' + ? OriginProp + : Instance[K]; } & { x?: number; y?: number; + origin?: OriginProp; } & Partial >; export type RefRectangle = Instance; export const Rectangle = React.forwardRef( - ({ x, y, ...props }, forwardedRef) => { + ({ x, y, origin, ...props }, forwardedRef) => { const { parent, registerEventShape, unregisterEventShape } = useTwo(); // Create the instance synchronously so it's available for refs immediately @@ -64,6 +67,9 @@ export const Rectangle = React.forwardRef( if (typeof x === 'number') rectangle.translation.x = x; if (typeof y === 'number') rectangle.translation.y = y; + // Update origin + applyOrigin(rectangle, origin); + // Update other properties (excluding event handlers) for (const key in shapeProps) { if (key in rectangle) { @@ -71,7 +77,7 @@ export const Rectangle = React.forwardRef( (rectangle as any)[key] = (shapeProps as any)[key]; } } - }, [shapeProps, rectangle, x, y]); + }, [shapeProps, rectangle, x, y, origin]); // Unregister on unmount only useEffect(() => { diff --git a/lib/RoundedRectangle.tsx b/lib/RoundedRectangle.tsx index 0a3c1cd..57f1199 100644 --- a/lib/RoundedRectangle.tsx +++ b/lib/RoundedRectangle.tsx @@ -7,7 +7,7 @@ import { PathProps } from './Path'; import { type EventHandlers } from './Properties'; import { EVENT_HANDLER_NAMES } from './Events'; -type RoundedRectangleProps = PathProps | 'width' | 'height' | 'radius'; +export type RoundedRectangleProps = PathProps | 'width' | 'height' | 'radius'; type ComponentProps = React.PropsWithChildren< { [K in Extract]?: Instance[K]; diff --git a/lib/SVG.tsx b/lib/SVG.tsx index 1f9e8ea..f5b41b0 100644 --- a/lib/SVG.tsx +++ b/lib/SVG.tsx @@ -3,21 +3,11 @@ import Two from 'two.js'; import { Context, TwoParentContext, TwoSizeContext, useTwo } from './Context'; import type { Group as Instance } from 'two.js/src/group'; -import { ShapeProps, type EventHandlers } from './Properties'; +import { type EventHandlers } from './Properties'; import { EVENT_HANDLER_NAMES } from './Events'; +import type { GroupProps } from './Group'; -// SVG-specific props -type GroupProps = - | ShapeProps - | 'fill' - | 'stroke' - | 'linewidth' - | 'cap' - | 'join' - | 'miter' - | 'closed' - | 'curved' - | 'automatic'; +export type SVGProps = GroupProps; type ComponentProps = React.PropsWithChildren< { diff --git a/lib/Sprite.tsx b/lib/Sprite.tsx index d55eeb0..7e77872 100644 --- a/lib/Sprite.tsx +++ b/lib/Sprite.tsx @@ -3,11 +3,12 @@ import Two from 'two.js'; import { useTwo } from './Context'; import type { Sprite as Instance } from 'two.js/src/effects/sprite'; +import type { Texture } from 'two.js/src/effects/texture'; import { RectangleProps } from './Rectangle'; -import { type EventHandlers } from './Properties'; +import { applyOrigin, type EventHandlers, type OriginProp } from './Properties'; import { EVENT_HANDLER_NAMES } from './Events'; -type SpriteProps = +export type SpriteProps = | RectangleProps | 'texture' | 'columns' @@ -20,13 +21,14 @@ type SpriteProps = type ComponentProps = React.PropsWithChildren< { - [K in Extract]?: K extends keyof Instance - ? Instance[K] - : never; + [K in Extract]?: K extends 'origin' + ? OriginProp + : Instance[K]; } & { - src?: string; + src?: string | Texture; x?: number; y?: number; + origin?: OriginProp; autoPlay?: boolean; } & Partial >; @@ -34,7 +36,7 @@ type ComponentProps = React.PropsWithChildren< export type RefSprite = Instance; export const Sprite = React.forwardRef( - ({ src, x, y, autoPlay, ...props }, forwardedRef) => { + ({ src, x, y, origin, autoPlay, ...props }, forwardedRef) => { const { parent, registerEventShape, unregisterEventShape } = useTwo(); // Create the instance synchronously so it's available for refs immediately @@ -78,6 +80,9 @@ export const Sprite = React.forwardRef( if (typeof x === 'number') sprite.translation.x = x; if (typeof y === 'number') sprite.translation.y = y; + // Update origin + applyOrigin(sprite, origin); + if (autoPlay) { sprite.play(); } else { @@ -91,7 +96,7 @@ export const Sprite = React.forwardRef( (sprite as any)[key] = (shapeProps as any)[key]; } } - }, [shapeProps, sprite, x, y, autoPlay]); + }, [shapeProps, sprite, x, y, origin, autoPlay]); // Unregister on unmount only useEffect(() => { diff --git a/lib/Star.tsx b/lib/Star.tsx index f3d0e62..88c5a3b 100644 --- a/lib/Star.tsx +++ b/lib/Star.tsx @@ -7,7 +7,7 @@ import { PathProps } from './Path'; import { type EventHandlers } from './Properties'; import { EVENT_HANDLER_NAMES } from './Events'; -type StarProps = PathProps | 'innerRadius' | 'outerRadius' | 'sides'; +export type StarProps = PathProps | 'innerRadius' | 'outerRadius' | 'sides'; type ComponentProps = React.PropsWithChildren< { [K in Extract]?: Instance[K]; diff --git a/lib/Text.tsx b/lib/Text.tsx index af76d22..af386f4 100644 --- a/lib/Text.tsx +++ b/lib/Text.tsx @@ -6,7 +6,7 @@ import type { Text as Instance } from 'two.js/src/text'; import { ShapeProps, type EventHandlers } from './Properties'; import { EVENT_HANDLER_NAMES } from './Events'; -type TextProps = +export type TextProps = | ShapeProps | 'value' | 'family' @@ -23,7 +23,10 @@ type TextProps = | 'visible' | 'fill' | 'stroke' - | 'dashes'; + | 'dashes' + | 'mask' + | 'clip' + | 'strokeAttenuation'; type ComponentProps = React.PropsWithChildren< { [K in Extract]?: Instance[K]; diff --git a/lib/main.ts b/lib/main.ts index 3505b7b..8d8cbc1 100644 --- a/lib/main.ts +++ b/lib/main.ts @@ -1,6 +1,6 @@ export { Provider as Canvas } from './Provider'; export { Context, useTwo, useFrame } from './Context'; -export { Group, type RefGroup } from './Group'; +export { Group, type RefGroup, type GroupProps } from './Group'; export { useZUI, useZUIState, @@ -9,31 +9,55 @@ export { type ZUIState, type ReadonlyRef, } from './ZUI'; -export { SVG, type RefSVG } from './SVG'; -export { Path, type RefPath } from './Path'; -export { Points, type RefPoints } from './Points'; -export { Text, type RefText } from './Text'; - -// Primitve exports -export { ArcSegment, type RefArcSegment } from './ArcSegment'; -export { Circle, type RefCircle } from './Circle'; -export { Ellipse, type RefEllipse } from './Ellipse'; -export { Image, type RefImage } from './Image'; -export { ImageSequence, type RefImageSequence } from './ImageSequence'; -export { Line, type RefLine } from './Line'; -export { Polygon, type RefPolygon } from './Polygon'; -export { Rectangle, type RefRectangle } from './Rectangle'; -export { RoundedRectangle, type RefRoundedRectangle } from './RoundedRectangle'; -export { Sprite, type RefSprite } from './Sprite'; -export { Star, type RefStar } from './Star'; +export { SVG, type RefSVG, type SVGProps } from './SVG'; +export { Path, type RefPath, type PathProps } from './Path'; +export { Points, type RefPoints, type PointsProps } from './Points'; +export { Text, type RefText, type TextProps } from './Text'; + +// Primitive exports +export { ArcSegment, type RefArcSegment, type ArcSegmentProps } from './ArcSegment'; +export { Circle, type RefCircle, type CircleProps } from './Circle'; +export { Ellipse, type RefEllipse, type EllipseProps } from './Ellipse'; +export { Image, type RefImage, type ImageProps } from './Image'; +export { ImageSequence, type RefImageSequence, type ImageSequenceProps } from './ImageSequence'; +export { Line, type RefLine, type LineProps } from './Line'; +export { Polygon, type RefPolygon, type PolygonProps } from './Polygon'; +export { Rectangle, type RefRectangle, type RectangleProps } from './Rectangle'; +export { RoundedRectangle, type RefRoundedRectangle, type RoundedRectangleProps } from './RoundedRectangle'; +export { Sprite, type RefSprite, type SpriteProps } from './Sprite'; +export { Star, type RefStar, type StarProps } from './Star'; // Gradient exports -export { LinearGradient, type RefLinearGradient } from './LinearGradient'; -export { RadialGradient, type RefRadialGradient } from './RadialGradient'; +export { LinearGradient, type RefLinearGradient, type LinearGradientProps } from './LinearGradient'; +export { RadialGradient, type RefRadialGradient, type RadialGradientProps } from './RadialGradient'; // Texture exports -export { Texture, type RefTexture } from './Texture'; +export { Texture, type RefTexture, type TextureProps } from './Texture'; // Event exports export type { TwoEvent, EventHandler, EventHandlers } from './Events'; +// Shared property types +export type { + ElementProps, + ShapeProps, + GradientProps, + OriginProp, +} from './Properties'; +export { + ELEMENT_PROPERTIES, + SHAPE_PROPERTIES, + GRADIENT_PROPERTIES, + applyOrigin, +} from './Properties'; + +// Property matrix exports +export { + PROPERTY_MATRIX, + SUPPORTED_TWO_VERSION, + type ComponentPropertyMatrix, + type PropertyOmission, + type OmissionCategory, +} from './propertyMatrix'; + + diff --git a/lib/propertyMatrix.ts b/lib/propertyMatrix.ts new file mode 100644 index 0000000..c440242 --- /dev/null +++ b/lib/propertyMatrix.ts @@ -0,0 +1,902 @@ +/** + * Property matrix for react-two.js components against Two.js v0.8.23. + * + * This module defines every exported component, its underlying Two.js class, + * all supported scene-relevant declarative properties, and intentional omissions + * (such as internal renderer state or computed properties) with explanations. + */ + +export const SUPPORTED_TWO_VERSION = '0.8.23' as const; + +export type OmissionCategory = + | 'read-only' + | 'renderer-internal' + | 'managed-by-react' + | 'imperative-method'; + +export interface PropertyOmission { + readonly property: string; + readonly reason: OmissionCategory; + readonly description: string; +} + +export interface ComponentPropertyMatrix { + readonly component: string; + readonly twoClass: string; + readonly supportedProps: readonly string[]; + readonly omittedProps: readonly PropertyOmission[]; +} + +const COMMON_SHAPE_OMISSIONS: readonly PropertyOmission[] = [ + { + property: 'worldMatrix', + reason: 'read-only', + description: + 'Computed world-space transformation matrix generated during rendering. Read via ref.current.worldMatrix.', + }, + { + property: 'renderer', + reason: 'renderer-internal', + description: + 'Internal renderer state and DOM elements (e.g. renderer.elem for SVG). Access imperatively via ref.current.renderer.', + }, + { + property: 'parent', + reason: 'managed-by-react', + description: + 'Hierarchy is managed declaratively by the React component tree.', + }, + { + property: 'clone', + reason: 'imperative-method', + description: 'Method to clone Two.js object. Call on ref.current.', + }, + { + property: 'copy', + reason: 'imperative-method', + description: 'Method to copy properties from another Two.js object.', + }, + { + property: 'addTo', + reason: 'imperative-method', + description: 'Scenegraph modification managed declaratively by React.', + }, + { + property: 'remove', + reason: 'imperative-method', + description: 'Scenegraph modification managed declaratively by React.', + }, +]; + +const COMMON_PATH_OMISSIONS: readonly PropertyOmission[] = [ + ...COMMON_SHAPE_OMISSIONS, + { + property: 'length', + reason: 'read-only', + description: + 'Computed arc length of path vertices. Read via ref.current.length.', + }, + { + property: 'subdivide', + reason: 'imperative-method', + description: 'Imperative vertex subdivision algorithm.', + }, + { + property: 'plot', + reason: 'imperative-method', + description: 'Method to recalculate path curves from anchors.', + }, +]; + +export const PROPERTY_MATRIX: Record = { + Group: { + component: 'Group', + twoClass: 'Two.Group', + supportedProps: [ + 'id', + 'className', + 'position', + 'translation', + 'rotation', + 'scale', + 'skewX', + 'skewY', + 'matrix', + 'fill', + 'stroke', + 'linewidth', + 'cap', + 'join', + 'miter', + 'closed', + 'curved', + 'automatic', + 'opacity', + 'visible', + 'mask', + 'beginning', + 'ending', + 'strokeAttenuation', + 'x', + 'y', + ], + omittedProps: [ + ...COMMON_SHAPE_OMISSIONS, + { + property: 'length', + reason: 'read-only', + description: + 'Computed sum of distances between all children lengths. Read via ref.current.length.', + }, + { + property: 'children', + reason: 'managed-by-react', + description: + 'Children are managed declaratively through React JSX children rather than direct prop assignment.', + }, + { + property: 'additions', + reason: 'renderer-internal', + description: + 'Internal renderer list of children to be appended. Maintained by Two.js scenegraph.', + }, + { + property: 'subtractions', + reason: 'renderer-internal', + description: + 'Internal renderer list of children to be removed. Maintained by Two.js scenegraph.', + }, + ], + }, + + SVG: { + component: 'SVG', + twoClass: 'Two.Group', + supportedProps: [ + 'id', + 'className', + 'position', + 'translation', + 'rotation', + 'scale', + 'skewX', + 'skewY', + 'matrix', + 'fill', + 'stroke', + 'linewidth', + 'cap', + 'join', + 'miter', + 'closed', + 'curved', + 'automatic', + 'opacity', + 'visible', + 'mask', + 'beginning', + 'ending', + 'strokeAttenuation', + 'src', + 'content', + 'shallow', + 'onLoad', + 'onError', + 'x', + 'y', + ], + omittedProps: [ + ...COMMON_SHAPE_OMISSIONS, + { + property: 'children', + reason: 'managed-by-react', + description: + 'Parsed SVG children are populated asynchronously into the group by two.load().', + }, + ], + }, + + Path: { + component: 'Path', + twoClass: 'Two.Path', + supportedProps: [ + 'id', + 'className', + 'position', + 'translation', + 'rotation', + 'scale', + 'skewX', + 'skewY', + 'matrix', + 'fill', + 'stroke', + 'linewidth', + 'opacity', + 'visible', + 'cap', + 'join', + 'miter', + 'closed', + 'curved', + 'automatic', + 'beginning', + 'ending', + 'dashes', + 'vertices', + 'mask', + 'clip', + 'strokeAttenuation', + 'manual', + 'x', + 'y', + ], + omittedProps: COMMON_PATH_OMISSIONS, + }, + + Rectangle: { + component: 'Rectangle', + twoClass: 'Two.Rectangle', + supportedProps: [ + 'id', + 'className', + 'position', + 'translation', + 'rotation', + 'scale', + 'skewX', + 'skewY', + 'matrix', + 'fill', + 'stroke', + 'linewidth', + 'opacity', + 'visible', + 'cap', + 'join', + 'miter', + 'closed', + 'curved', + 'automatic', + 'beginning', + 'ending', + 'dashes', + 'vertices', + 'mask', + 'clip', + 'strokeAttenuation', + 'width', + 'height', + 'origin', + 'x', + 'y', + ], + omittedProps: COMMON_PATH_OMISSIONS, + }, + + Circle: { + component: 'Circle', + twoClass: 'Two.Circle', + supportedProps: [ + 'id', + 'className', + 'position', + 'translation', + 'rotation', + 'scale', + 'skewX', + 'skewY', + 'matrix', + 'fill', + 'stroke', + 'linewidth', + 'opacity', + 'visible', + 'cap', + 'join', + 'miter', + 'closed', + 'curved', + 'automatic', + 'beginning', + 'ending', + 'dashes', + 'vertices', + 'mask', + 'clip', + 'strokeAttenuation', + 'radius', + 'resolution', + 'x', + 'y', + ], + omittedProps: COMMON_PATH_OMISSIONS, + }, + + Ellipse: { + component: 'Ellipse', + twoClass: 'Two.Ellipse', + supportedProps: [ + 'id', + 'className', + 'position', + 'translation', + 'rotation', + 'scale', + 'skewX', + 'skewY', + 'matrix', + 'fill', + 'stroke', + 'linewidth', + 'opacity', + 'visible', + 'cap', + 'join', + 'miter', + 'closed', + 'curved', + 'automatic', + 'beginning', + 'ending', + 'dashes', + 'vertices', + 'mask', + 'clip', + 'strokeAttenuation', + 'width', + 'height', + 'resolution', + 'x', + 'y', + ], + omittedProps: COMMON_PATH_OMISSIONS, + }, + + Line: { + component: 'Line', + twoClass: 'Two.Line', + supportedProps: [ + 'id', + 'className', + 'position', + 'translation', + 'rotation', + 'scale', + 'skewX', + 'skewY', + 'matrix', + 'fill', + 'stroke', + 'linewidth', + 'opacity', + 'visible', + 'cap', + 'join', + 'miter', + 'closed', + 'curved', + 'automatic', + 'beginning', + 'ending', + 'dashes', + 'vertices', + 'mask', + 'clip', + 'strokeAttenuation', + 'left', + 'right', + 'x1', + 'y1', + 'x2', + 'y2', + ], + omittedProps: COMMON_PATH_OMISSIONS, + }, + + Polygon: { + component: 'Polygon', + twoClass: 'Two.Polygon', + supportedProps: [ + 'id', + 'className', + 'position', + 'translation', + 'rotation', + 'scale', + 'skewX', + 'skewY', + 'matrix', + 'fill', + 'stroke', + 'linewidth', + 'opacity', + 'visible', + 'cap', + 'join', + 'miter', + 'closed', + 'curved', + 'automatic', + 'beginning', + 'ending', + 'dashes', + 'vertices', + 'mask', + 'clip', + 'strokeAttenuation', + 'width', + 'height', + 'sides', + 'radius', + 'x', + 'y', + ], + omittedProps: COMMON_PATH_OMISSIONS, + }, + + RoundedRectangle: { + component: 'RoundedRectangle', + twoClass: 'Two.RoundedRectangle', + supportedProps: [ + 'id', + 'className', + 'position', + 'translation', + 'rotation', + 'scale', + 'skewX', + 'skewY', + 'matrix', + 'fill', + 'stroke', + 'linewidth', + 'opacity', + 'visible', + 'cap', + 'join', + 'miter', + 'closed', + 'curved', + 'automatic', + 'beginning', + 'ending', + 'dashes', + 'vertices', + 'mask', + 'clip', + 'strokeAttenuation', + 'width', + 'height', + 'radius', + 'x', + 'y', + ], + omittedProps: COMMON_PATH_OMISSIONS, + }, + + Star: { + component: 'Star', + twoClass: 'Two.Star', + supportedProps: [ + 'id', + 'className', + 'position', + 'translation', + 'rotation', + 'scale', + 'skewX', + 'skewY', + 'matrix', + 'fill', + 'stroke', + 'linewidth', + 'opacity', + 'visible', + 'cap', + 'join', + 'miter', + 'closed', + 'curved', + 'automatic', + 'beginning', + 'ending', + 'dashes', + 'vertices', + 'mask', + 'clip', + 'strokeAttenuation', + 'innerRadius', + 'outerRadius', + 'sides', + 'x', + 'y', + ], + omittedProps: COMMON_PATH_OMISSIONS, + }, + + ArcSegment: { + component: 'ArcSegment', + twoClass: 'Two.ArcSegment', + supportedProps: [ + 'id', + 'className', + 'position', + 'translation', + 'rotation', + 'scale', + 'skewX', + 'skewY', + 'matrix', + 'fill', + 'stroke', + 'linewidth', + 'opacity', + 'visible', + 'cap', + 'join', + 'miter', + 'closed', + 'curved', + 'automatic', + 'beginning', + 'ending', + 'dashes', + 'vertices', + 'mask', + 'clip', + 'strokeAttenuation', + 'startAngle', + 'endAngle', + 'innerRadius', + 'outerRadius', + 'resolution', + 'x', + 'y', + ], + omittedProps: COMMON_PATH_OMISSIONS, + }, + + Points: { + component: 'Points', + twoClass: 'Two.Points', + supportedProps: [ + 'id', + 'className', + 'position', + 'translation', + 'rotation', + 'scale', + 'skewX', + 'skewY', + 'matrix', + 'fill', + 'stroke', + 'linewidth', + 'opacity', + 'visible', + 'size', + 'sizeAttenuation', + 'beginning', + 'ending', + 'dashes', + 'vertices', + 'strokeAttenuation', + 'x', + 'y', + ], + omittedProps: [ + ...COMMON_SHAPE_OMISSIONS, + { + property: 'mask', + reason: 'read-only', + description: + 'Two.Points does not implement mask in Two.js. Wrap in a Two.Group with mask instead.', + }, + { + property: 'clip', + reason: 'read-only', + description: 'Two.Points does not implement clip in Two.js.', + }, + { + property: 'length', + reason: 'read-only', + description: + 'Number of vertices in the points collection. Read via ref.current.length.', + }, + ], + }, + + Text: { + component: 'Text', + twoClass: 'Two.Text', + supportedProps: [ + 'id', + 'className', + 'position', + 'translation', + 'rotation', + 'scale', + 'skewX', + 'skewY', + 'matrix', + 'value', + 'family', + 'size', + 'leading', + 'alignment', + 'linewidth', + 'style', + 'weight', + 'decoration', + 'direction', + 'baseline', + 'opacity', + 'visible', + 'fill', + 'stroke', + 'dashes', + 'mask', + 'clip', + 'strokeAttenuation', + 'x', + 'y', + ], + omittedProps: COMMON_SHAPE_OMISSIONS, + }, + + Image: { + component: 'Image', + twoClass: 'Two.Image', + supportedProps: [ + 'id', + 'className', + 'position', + 'translation', + 'rotation', + 'scale', + 'skewX', + 'skewY', + 'matrix', + 'fill', + 'stroke', + 'linewidth', + 'opacity', + 'visible', + 'cap', + 'join', + 'miter', + 'closed', + 'curved', + 'automatic', + 'beginning', + 'ending', + 'dashes', + 'vertices', + 'mask', + 'clip', + 'strokeAttenuation', + 'width', + 'height', + 'origin', + 'mode', + 'texture', + 'src', + 'x', + 'y', + ], + omittedProps: COMMON_PATH_OMISSIONS, + }, + + Sprite: { + component: 'Sprite', + twoClass: 'Two.Sprite', + supportedProps: [ + 'id', + 'className', + 'position', + 'translation', + 'rotation', + 'scale', + 'skewX', + 'skewY', + 'matrix', + 'fill', + 'stroke', + 'linewidth', + 'opacity', + 'visible', + 'cap', + 'join', + 'miter', + 'closed', + 'curved', + 'automatic', + 'beginning', + 'ending', + 'dashes', + 'vertices', + 'mask', + 'clip', + 'strokeAttenuation', + 'width', + 'height', + 'origin', + 'texture', + 'columns', + 'rows', + 'frameRate', + 'index', + 'firstFrame', + 'lastFrame', + 'loop', + 'src', + 'autoPlay', + 'x', + 'y', + ], + omittedProps: [ + ...COMMON_PATH_OMISSIONS, + { + property: 'play', + reason: 'imperative-method', + description: + 'Playback method. Available declaratively via autoPlay prop or ref.current.play().', + }, + { + property: 'pause', + reason: 'imperative-method', + description: + 'Playback method. Available declaratively via autoPlay={false} or ref.current.pause().', + }, + { + property: 'stop', + reason: 'imperative-method', + description: 'Playback method. Call ref.current.stop().', + }, + ], + }, + + ImageSequence: { + component: 'ImageSequence', + twoClass: 'Two.ImageSequence', + supportedProps: [ + 'id', + 'className', + 'position', + 'translation', + 'rotation', + 'scale', + 'skewX', + 'skewY', + 'matrix', + 'fill', + 'stroke', + 'linewidth', + 'opacity', + 'visible', + 'cap', + 'join', + 'miter', + 'closed', + 'curved', + 'automatic', + 'beginning', + 'ending', + 'dashes', + 'vertices', + 'mask', + 'clip', + 'strokeAttenuation', + 'width', + 'height', + 'origin', + 'textures', + 'frameRate', + 'index', + 'firstFrame', + 'lastFrame', + 'loop', + 'src', + 'autoPlay', + 'x', + 'y', + ], + omittedProps: [ + ...COMMON_PATH_OMISSIONS, + { + property: 'play', + reason: 'imperative-method', + description: + 'Playback method. Available declaratively via autoPlay prop or ref.current.play().', + }, + { + property: 'pause', + reason: 'imperative-method', + description: + 'Playback method. Available declaratively via autoPlay={false} or ref.current.pause().', + }, + { + property: 'stop', + reason: 'imperative-method', + description: 'Playback method. Call ref.current.stop().', + }, + ], + }, + + LinearGradient: { + component: 'LinearGradient', + twoClass: 'Two.LinearGradient', + supportedProps: [ + 'id', + 'className', + 'spread', + 'units', + 'stops', + 'left', + 'right', + 'x1', + 'y1', + 'x2', + 'y2', + ], + omittedProps: [ + { + property: 'renderer', + reason: 'renderer-internal', + description: + 'Internal renderer state and DOM element (e.g. SVGLinearGradientElement). Access via ref.current.renderer.', + }, + ], + }, + + RadialGradient: { + component: 'RadialGradient', + twoClass: 'Two.RadialGradient', + supportedProps: [ + 'id', + 'className', + 'spread', + 'units', + 'stops', + 'center', + 'radius', + 'focal', + 'x', + 'y', + 'focalX', + 'focalY', + ], + omittedProps: [ + { + property: 'renderer', + reason: 'renderer-internal', + description: + 'Internal renderer state and DOM element (e.g. SVGRadialGradientElement). Access via ref.current.renderer.', + }, + ], + }, + + Texture: { + component: 'Texture', + twoClass: 'Two.Texture', + supportedProps: [ + 'id', + 'className', + 'src', + 'loaded', + 'repeat', + 'scale', + 'offset', + 'image', + ], + omittedProps: [ + { + property: 'renderer', + reason: 'renderer-internal', + description: + 'Internal renderer state and DOM pattern/canvas. Access via ref.current.renderer.', + }, + ], + }, +}; diff --git a/tests/propertyMatrix.test.tsx b/tests/propertyMatrix.test.tsx new file mode 100644 index 0000000..2a09f58 --- /dev/null +++ b/tests/propertyMatrix.test.tsx @@ -0,0 +1,472 @@ +import { describe, it, expect, expectTypeOf } from 'vitest'; +import React, { createRef } from 'react'; +import { render, act } from '@testing-library/react'; +import Two from 'two.js'; +import { + Canvas, + Group, + SVG, + Path, + Points, + Text, + ArcSegment, + Circle, + Ellipse, + Image, + ImageSequence, + Line, + Polygon, + Rectangle, + RoundedRectangle, + Sprite, + Star, + LinearGradient, + RadialGradient, + Texture, + PROPERTY_MATRIX, + SUPPORTED_TWO_VERSION, + type RefPath, + type RefGroup, + type RefText, + type RefPoints, + type RefRectangle, + type RefCircle, + type RefImage, + type RefSprite, + type RefImageSequence, + type RefSVG, + type PathProps, + type GroupProps, + type TextProps, + type PointsProps, + type RectangleProps, + type SpriteProps, + type ImageProps, + type ImageSequenceProps, + type ElementProps, + type ShapeProps, + type OriginProp, + ELEMENT_PROPERTIES, + SHAPE_PROPERTIES, + GRADIENT_PROPERTIES, + applyOrigin, +} from '../lib/main'; + +describe('Property Matrix & Declarative Coverage (Issue #30)', () => { + describe('Compile-Time Type Tests', () => { + it('should include mask, clip, and strokeAttenuation in PathProps', () => { + expectTypeOf<'mask'>().toExtend(); + expectTypeOf<'clip'>().toExtend(); + expectTypeOf<'strokeAttenuation'>().toExtend(); + }); + + it('should include mask, beginning, ending, and strokeAttenuation in GroupProps', () => { + expectTypeOf<'mask'>().toExtend(); + expectTypeOf<'beginning'>().toExtend(); + expectTypeOf<'ending'>().toExtend(); + expectTypeOf<'strokeAttenuation'>().toExtend(); + }); + + it('should include mask, clip, and strokeAttenuation in TextProps', () => { + expectTypeOf<'mask'>().toExtend(); + expectTypeOf<'clip'>().toExtend(); + expectTypeOf<'strokeAttenuation'>().toExtend(); + }); + + it('should include strokeAttenuation in PointsProps', () => { + expectTypeOf<'strokeAttenuation'>().toExtend(); + }); + + it('should include origin in RectangleProps, ImageProps, SpriteProps, ImageSequenceProps', () => { + expectTypeOf<'origin'>().toExtend(); + expectTypeOf<'origin'>().toExtend(); + expectTypeOf<'origin'>().toExtend(); + expectTypeOf<'origin'>().toExtend(); + }); + + it('should type check Rectangle origin accepting Vector, object literal, and tuple', () => { + type RectProps = React.ComponentProps; + expectTypeOf<{ origin?: Two.Vector }>().toExtend(); + expectTypeOf<{ origin?: { x?: number; y?: number } }>().toExtend(); + expectTypeOf<{ origin?: [number, number] }>().toExtend(); + }); + + it('should type check Sprite src accepting string or Two.Texture', () => { + type SprProps = React.ComponentProps; + expectTypeOf<{ src?: string }>().toExtend(); + expectTypeOf<{ src?: InstanceType }>().toExtend(); + }); + + it('should type check Path mask, clip, and strokeAttenuation props', () => { + type PProps = React.ComponentProps; + expectTypeOf<{ mask?: InstanceType | null }>().toExtend(); + expectTypeOf<{ clip?: boolean }>().toExtend(); + expectTypeOf<{ strokeAttenuation?: boolean }>().toExtend(); + }); + + it('should type check Group mask, beginning, ending, and strokeAttenuation props', () => { + type GProps = React.ComponentProps; + expectTypeOf<{ mask?: InstanceType | null }>().toExtend(); + expectTypeOf<{ beginning?: number }>().toExtend(); + expectTypeOf<{ ending?: number }>().toExtend(); + expectTypeOf<{ strokeAttenuation?: boolean }>().toExtend(); + }); + + it('should exclude internal/read-only fields renderer and worldMatrix from ElementProps and ShapeProps', () => { + expectTypeOf<'renderer'>().not.toExtend(); + expectTypeOf<'worldMatrix'>().not.toExtend(); + expectTypeOf<'renderer'>().not.toExtend(); + expectTypeOf<'id'>().toExtend(); + expectTypeOf<'className'>().toExtend(); + expectTypeOf<'translation'>().toExtend(); + }); + }); + + describe('Shared Property Lists', () => { + it('should not contain renderer or worldMatrix in exported property arrays', () => { + expect((ELEMENT_PROPERTIES as readonly string[]).includes('renderer')).toBe(false); + expect((SHAPE_PROPERTIES as readonly string[]).includes('renderer')).toBe(false); + expect((SHAPE_PROPERTIES as readonly string[]).includes('worldMatrix')).toBe(false); + expect((ELEMENT_PROPERTIES as readonly string[]).includes('id')).toBe(true); + expect((SHAPE_PROPERTIES as readonly string[]).includes('translation')).toBe(true); + expect((GRADIENT_PROPERTIES as readonly string[]).includes('stops')).toBe(true); + }); + + it('should type check OriginProp accepting Vector, object literal, and tuple', () => { + expectTypeOf().toExtend(); + expectTypeOf<{ x: number; y: number }>().toExtend(); + expectTypeOf<[number, number]>().toExtend(); + }); + }); + + describe('applyOrigin helper', () => { + it('should apply Two.Vector directly', () => { + const shape = { origin: new Two.Vector(0, 0) }; + const newVec = new Two.Vector(12, 34); + applyOrigin(shape, newVec); + expect(shape.origin).toBe(newVec); + }); + + it('should apply array tuple coordinates', () => { + const shape = { origin: new Two.Vector(0, 0) }; + applyOrigin(shape, [56, 78]); + expect(shape.origin.x).toBe(56); + expect(shape.origin.y).toBe(78); + }); + + it('should apply object literal coordinates', () => { + const shape = { origin: new Two.Vector(0, 0) }; + applyOrigin(shape, { x: 99, y: 88 }); + expect(shape.origin.x).toBe(99); + expect(shape.origin.y).toBe(88); + }); + + it('should no-op on undefined origin', () => { + const shape = { origin: new Two.Vector(10, 20) }; + applyOrigin(shape, undefined); + expect(shape.origin.x).toBe(10); + expect(shape.origin.y).toBe(20); + }); + }); + + describe('Property Matrix Definition', () => { + it('should verify supported Two.js version is 0.8.23', () => { + expect(SUPPORTED_TWO_VERSION).toBe('0.8.23'); + }); + + it('should contain entries for all 19 exported components', () => { + const expectedComponents = [ + 'Group', + 'SVG', + 'Path', + 'Rectangle', + 'Circle', + 'Ellipse', + 'Line', + 'Polygon', + 'RoundedRectangle', + 'Star', + 'ArcSegment', + 'Points', + 'Text', + 'Image', + 'Sprite', + 'ImageSequence', + 'LinearGradient', + 'RadialGradient', + 'Texture', + ]; + + for (const name of expectedComponents) { + expect(PROPERTY_MATRIX[name]).toBeDefined(); + expect(PROPERTY_MATRIX[name].component).toBe(name); + expect(PROPERTY_MATRIX[name].supportedProps.length).toBeGreaterThan(0); + expect(PROPERTY_MATRIX[name].omittedProps.length).toBeGreaterThan(0); + } + }); + + it('should document intentional omissions with reasons for every component', () => { + for (const compName in PROPERTY_MATRIX) { + const matrix = PROPERTY_MATRIX[compName]; + for (const omission of matrix.omittedProps) { + expect(omission.property).toBeTruthy(); + expect(['read-only', 'renderer-internal', 'managed-by-react', 'imperative-method']).toContain( + omission.reason + ); + expect(omission.description).toBeTruthy(); + } + } + }); + }); + + describe('Runtime Declarative Prop Updates', () => { + it('should apply mask and clip on Path declaratively', () => { + const pathRef = createRef(); + const maskShape = new Two.Circle(0, 0, 50); + + render( + + + + ); + + expect(pathRef.current).not.toBeNull(); + expect(pathRef.current?.mask).toBe(maskShape); + expect(maskShape.clip).toBe(true); + expect(pathRef.current?.clip).toBe(true); + }); + + it('should apply mask on Group declaratively', () => { + const groupRef = createRef(); + const maskShape = new Two.Rectangle(0, 0, 100, 100); + + render( + + + + ); + + expect(groupRef.current).not.toBeNull(); + expect(groupRef.current?.mask).toBe(maskShape); + expect(maskShape.clip).toBe(true); + }); + + it('should apply mask and clip on Text declaratively', () => { + const textRef = createRef(); + const maskShape = new Two.Circle(0, 0, 30); + + render( + + + + ); + + expect(textRef.current).not.toBeNull(); + expect(textRef.current?.mask).toBe(maskShape); + expect(textRef.current?.clip).toBe(true); + }); + + it('should apply strokeAttenuation across Path, Group, Text, Points, Circle', () => { + const pathRef = createRef(); + const groupRef = createRef(); + const textRef = createRef(); + const pointsRef = createRef(); + const circleRef = createRef(); + + render( + + + + + + + + ); + + expect(pathRef.current?.strokeAttenuation).toBe(false); + expect(groupRef.current?.strokeAttenuation).toBe(false); + expect(textRef.current?.strokeAttenuation).toBe(false); + expect(pointsRef.current?.strokeAttenuation).toBe(false); + expect(circleRef.current?.strokeAttenuation).toBe(false); + }); + + it('should apply beginning and ending on Group and SVG declaratively', () => { + const groupRef = createRef(); + const svgRef = createRef(); + + render( + + + + + ); + + expect(groupRef.current?.beginning).toBe(0.2); + expect(groupRef.current?.ending).toBe(0.8); + expect(svgRef.current?.beginning).toBe(0.15); + expect(svgRef.current?.ending).toBe(0.85); + }); + + describe('Rectangle and derived shapes origin support', () => { + it('should support origin as Two.Vector on Rectangle', () => { + const rectRef = createRef(); + const originVec = new Two.Vector(25, 35); + + render( + + + + ); + + expect(rectRef.current?.origin.x).toBe(25); + expect(rectRef.current?.origin.y).toBe(35); + }); + + it('should support origin as object literal { x, y } on Rectangle', () => { + const rectRef = createRef(); + + render( + + + + ); + + expect(rectRef.current?.origin.x).toBe(50); + expect(rectRef.current?.origin.y).toBe(60); + }); + + it('should support origin as tuple [x, y] on Rectangle', () => { + const rectRef = createRef(); + + render( + + + + ); + + expect(rectRef.current?.origin.x).toBe(75); + expect(rectRef.current?.origin.y).toBe(85); + }); + + it('should support origin on Image', () => { + const imageRef = createRef(); + + render( + + + + ); + + expect(imageRef.current?.origin.x).toBe(10); + expect(imageRef.current?.origin.y).toBe(20); + }); + + it('should support origin on Sprite', () => { + const spriteRef = createRef(); + + render( + + + + ); + + expect(spriteRef.current?.origin.x).toBe(30); + expect(spriteRef.current?.origin.y).toBe(40); + }); + + it('should support origin on ImageSequence', () => { + const seqRef = createRef(); + + render( + + + + ); + + expect(seqRef.current?.origin.x).toBe(50); + expect(seqRef.current?.origin.y).toBe(70); + }); + }); + + describe('Sprite src with Two.Texture', () => { + it('should accept Two.Texture as Sprite src', () => { + const spriteRef = createRef(); + const texture = new Two.Texture(); + + render( + + + + ); + + expect(spriteRef.current?.texture).toBe(texture); + }); + }); + + describe('Dynamic prop updates', () => { + it('should update strokeAttenuation dynamically on re-render', () => { + const pathRef = createRef(); + + const { rerender } = render( + + + + ); + + expect(pathRef.current?.strokeAttenuation).toBe(true); + + act(() => { + rerender( + + + + ); + }); + + expect(pathRef.current?.strokeAttenuation).toBe(false); + }); + + it('should update origin dynamically on re-render', () => { + const rectRef = createRef(); + + const { rerender } = render( + + + + ); + + expect(rectRef.current?.origin.x).toBe(10); + expect(rectRef.current?.origin.y).toBe(10); + + act(() => { + rerender( + + + + ); + }); + + expect(rectRef.current?.origin.x).toBe(90); + expect(rectRef.current?.origin.y).toBe(90); + }); + }); + + describe('Primitives and Gradients coverage', () => { + it('should render all primitives and gradients inside Canvas with props', () => { + render( + + + + + + + + + + + + ); + }); + }); + }); +});