diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 13304f0791..f921d5f023 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,4 +1,4 @@ -name: Test +name: test packages on: push: @@ -22,5 +22,5 @@ jobs: - name: Install Dependencies run: pnpm install --frozen-lockfile - name: Run Tests - run: pnpm turbo test + run: pnpm turbo test --filter='!./crates/*' diff --git a/.gitignore b/.gitignore index 51da199c84..b89c5c2492 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,3 @@ - -# Created by https://www.toptal.com/developers/gitignore/api/node,react,macos,windows,visualstudiocode -# Edit at https://www.toptal.com/developers/gitignore?templates=node,react,macos,windows,visualstudiocode - ### macOS ### # General .DS_Store @@ -197,11 +193,13 @@ $RECYCLE.BIN/ # Windows shortcuts *.lnk -# End of https://www.toptal.com/developers/gitignore/api/node,react,macos,windows,visualstudiocode .vercel # Turborepo .turbo # node-compile-cache -node-compile-cache/ \ No newline at end of file +node-compile-cache/ + +# rust +/target \ No newline at end of file diff --git a/.ref/canvaskit/canvaskit.d.ts b/.ref/canvaskit/canvaskit.d.ts new file mode 100644 index 0000000000..192b842c9f --- /dev/null +++ b/.ref/canvaskit/canvaskit.d.ts @@ -0,0 +1,5366 @@ +// Minimum TypeScript Version: 4.4 +/// + +export default function CanvasKitInit( + opts?: CanvasKitInitOptions +): Promise; + +export interface CanvasKitInitOptions { + /** + * This callback will be invoked when the CanvasKit loader needs to fetch a file (e.g. + * the blob of WASM code). The correct url prefix should be applied. + * @param file - the name of the file that is about to be loaded. + */ + locateFile(file: string): string; +} + +export interface CanvasKit { + // Helpers + /** + * Constructs a Color with the same API as CSS's rgba(), that is + * Internally, Colors are four unpremultiplied 32-bit floats: r, g, b, a. + * In order to construct one with more precision or in a wider gamut, + * use CanvasKit.Color4f(). + * + * @param r - red value, clamped to [0, 255]. + * @param g - green value, clamped to [0, 255]. + * @param b - blue value, clamped to [0, 255]. + * @param a - alpha value, from 0 to 1.0. By default is 1.0 (opaque). + */ + Color(r: number, g: number, b: number, a?: number): Color; + + /** + * Construct a 4-float color. Float values are typically between 0.0 and 1.0. + * @param r - red value. + * @param g - green value. + * @param b - blue value. + * @param a - alpha value. By default is 1.0 (opaque). + */ + Color4f(r: number, g: number, b: number, a?: number): Color; + + /** + * Constructs a Color as a 32 bit unsigned integer, with 8 bits assigned to each channel. + * Channels are expected to be between 0 and 255 and will be clamped as such. + * If a is omitted, it will be 255 (opaque). + * + * This is not the preferred way to use colors in Skia APIs, use Color or Color4f. + * @param r - red value, clamped to [0, 255]. + * @param g - green value, clamped to [0, 255]. + * @param b - blue value, clamped to [0, 255]. + * @param a - alpha value, from 0 to 1.0. By default is 1.0 (opaque). + */ + ColorAsInt(r: number, g: number, b: number, a?: number): ColorInt; + + /** + * Returns a css style [r, g, b, a] where r, g, b are returned as + * ints in the range [0, 255] and where a is scaled between 0 and 1.0. + * [Deprecated] - this is trivial now that Color is 4 floats. + */ + getColorComponents(c: Color): number[]; + + /** + * Takes in a CSS color value and returns a CanvasKit.Color + * (which is an array of 4 floats in RGBA order). An optional colorMap + * may be provided which maps custom strings to values. + * In the CanvasKit canvas2d shim layer, we provide this map for processing + * canvas2d calls, but not here for code size reasons. + */ + parseColorString(color: string, colorMap?: Record): Color; + + /** + * Returns a copy of the passed in color with a new alpha value applied. + * [Deprecated] - this is trivial now that Color is 4 floats. + */ + multiplyByAlpha(c: Color, alpha: number): Color; + + /** + * Computes color values for one-pass tonal alpha. + * Note, if malloced colors are passed in, the memory pointed at by the MallocObj + * will be overwritten with the computed tonal colors (and thus the return val can be + * ignored). + * @param colors + */ + computeTonalColors(colors: TonalColorsInput): TonalColorsOutput; + + /** + * Returns a rectangle with the given paramaters. See Rect.h for more. + * @param left - The x coordinate of the upper-left corner. + * @param top - The y coordinate of the upper-left corner. + * @param right - The x coordinate of the lower-right corner. + * @param bottom - The y coordinate of the lower-right corner. + */ + LTRBRect(left: number, top: number, right: number, bottom: number): Rect; + + /** + * Returns a rectangle with the given paramaters. See Rect.h for more. + * @param x - The x coordinate of the upper-left corner. + * @param y - The y coordinate of the upper-left corner. + * @param width - The width of the rectangle. + * @param height - The height of the rectangle. + */ + XYWHRect(x: number, y: number, width: number, height: number): Rect; + + /** + * Returns a rectangle with the given integer paramaters. See Rect.h for more. + * @param left - The x coordinate of the upper-left corner. + * @param top - The y coordinate of the upper-left corner. + * @param right - The x coordinate of the lower-right corner. + * @param bottom - The y coordinate of the lower-right corner. + */ + LTRBiRect(left: number, top: number, right: number, bottom: number): IRect; + + /** + * Returns a rectangle with the given paramaters. See Rect.h for more. + * @param x - The x coordinate of the upper-left corner. + * @param y - The y coordinate of the upper-left corner. + * @param width - The width of the rectangle. + * @param height - The height of the rectangle. + */ + XYWHiRect(x: number, y: number, width: number, height: number): IRect; + + /** + * Returns a rectangle with rounded corners consisting of the given rectangle and + * the same radiusX and radiusY for all four corners. + * @param rect - The base rectangle. + * @param rx - The radius of the corners in the x direction. + * @param ry - The radius of the corners in the y direction. + */ + RRectXY(rect: InputRect, rx: number, ry: number): RRect; + + /** + * Generate bounding box for shadows relative to path. Includes both the ambient and spot + * shadow bounds. This pairs with Canvas.drawShadow(). + * See SkShadowUtils.h for more details. + * @param ctm - Current transformation matrix to device space. + * @param path - The occluder used to generate the shadows. + * @param zPlaneParams - Values for the plane function which returns the Z offset of the + * occluder from the canvas based on local x and y values (the current + * matrix is not applied). + * @param lightPos - The 3D position of the light relative to the canvas plane. This is + * independent of the canvas's current matrix. + * @param lightRadius - The radius of the disc light. + * @param flags - See SkShadowUtils.h; 0 means use default options. + * @param dstRect - if provided, the bounds will be copied into this rect instead of allocating + * a new one. + * @returns The bounding rectangle or null if it could not be computed. + */ + getShadowLocalBounds( + ctm: InputMatrix, + path: Path, + zPlaneParams: InputVector3, + lightPos: InputVector3, + lightRadius: number, + flags: number, + dstRect?: Rect + ): Rect | null; + + /** + * Malloc returns a TypedArray backed by the C++ memory of the + * given length. It should only be used by advanced users who + * can manage memory and initialize values properly. When used + * correctly, it can save copying of data between JS and C++. + * When used incorrectly, it can lead to memory leaks. + * Any memory allocated by CanvasKit.Malloc needs to be released with CanvasKit.Free. + * + * const mObj = CanvasKit.Malloc(Float32Array, 20); + * Get a TypedArray view around the malloc'd memory (this does not copy anything). + * const ta = mObj.toTypedArray(); + * // store data into ta + * const cf = CanvasKit.ColorFilter.MakeMatrix(ta); // mObj could also be used. + * + * // eventually... + * CanvasKit.Free(mObj); + * + * @param typedArray - constructor for the typedArray. + * @param len - number of *elements* to store. + */ + Malloc(typedArray: TypedArrayConstructor, len: number): MallocObj; + + /** + * As Malloc but for GlyphIDs. This helper exists to make sure the JS side and the C++ side + * stay in agreement with how wide GlyphIDs are. + * @param len - number of GlyphIDs to make space for. + */ + MallocGlyphIDs(len: number): MallocObj; + + /** + * Free frees the memory returned by Malloc. + * Any memory allocated by CanvasKit.Malloc needs to be released with CanvasKit.Free. + */ + Free(m: MallocObj): void; + + // Surface related functions + /** + * Creates a Surface on a given canvas. If both GPU and CPU modes have been compiled in, this + * will first try to create a GPU surface and then fallback to a CPU one if that fails. If just + * the CPU mode has been compiled in, a CPU surface will be created. + * @param canvas - either a canvas or a string with the DOM id of it. + * @deprecated - Use MakeSWCanvasSurface, MakeWebGLCanvasSurface, or MakeGPUCanvasSurface. + */ + MakeCanvasSurface( + canvas: HTMLCanvasElement | OffscreenCanvas | string + ): Surface | null; + + /** + * Creates a Raster (CPU) Surface that will draw into the provided Malloc'd buffer. This allows + * clients to efficiently be able to read the current pixels w/o having to copy. + * The length of pixels must be at least height * bytesPerRow bytes big. + * @param ii + * @param pixels + * @param bytesPerRow - How many bytes are per row. This is at least width * bytesPerColorType. For example, + * an 8888 ColorType has 4 bytes per pixel, so a 5 pixel wide 8888 surface needs at least + * 5 * 4 = 20 bytesPerRow. Some clients may have more than the usual to make the data line + * up with a particular multiple. + */ + MakeRasterDirectSurface( + ii: ImageInfo, + pixels: MallocObj, + bytesPerRow: number + ): Surface | null; + + /** + * Creates a CPU backed (aka raster) surface. + * @param canvas - either a canvas or a string with the DOM id of it. + */ + MakeSWCanvasSurface( + canvas: HTMLCanvasElement | OffscreenCanvas | string + ): Surface | null; + + /** + * A helper for creating a WebGL backed (aka GPU) surface and falling back to a CPU surface if + * the GPU one cannot be created. This works for both WebGL 1 and WebGL 2. + * @param canvas - Either a canvas or a string with the DOM id of it. + * @param colorSpace - One of the supported color spaces. Default is SRGB. + * @param opts - Options that will get passed to the creation of the WebGL context. + */ + MakeWebGLCanvasSurface( + canvas: HTMLCanvasElement | OffscreenCanvas | string, + colorSpace?: ColorSpace, + opts?: WebGLOptions + ): Surface | null; + + /** + * Returns a CPU backed surface with the given dimensions, an SRGB colorspace, Unpremul + * alphaType and 8888 color type. The pixels belonging to this surface will be in memory and + * not visible. + * @param width - number of pixels of the width of the drawable area. + * @param height - number of pixels of the height of the drawable area. + */ + MakeSurface(width: number, height: number): Surface | null; + + /** + * Creates a WebGL Context from the given canvas with the given options. If options are omitted, + * sensible defaults will be used. + * @param canvas + * @param opts + */ + GetWebGLContext( + canvas: HTMLCanvasElement | OffscreenCanvas, + opts?: WebGLOptions + ): WebGLContextHandle; + + /** + * Creates a GrDirectContext from the given WebGL Context. + * @param ctx + * @deprecated Use MakeWebGLContext instead. + */ + MakeGrContext(ctx: WebGLContextHandle): GrDirectContext | null; + + /** + * Creates a GrDirectContext from the given WebGL Context. + * @param ctx + */ + MakeWebGLContext(ctx: WebGLContextHandle): GrDirectContext | null; + + /** + * Creates a Surface that will be drawn to the given GrDirectContext (and show up on screen). + * @param ctx + * @param width - number of pixels of the width of the visible area. + * @param height - number of pixels of the height of the visible area. + * @param colorSpace + * @param sampleCount - sample count value from GL_SAMPLES. If not provided this will be looked up from + * the canvas. + * @param stencil - stencil count value from GL_STENCIL_BITS. If not provided this will be looked up + * from the WebGL Context. + */ + MakeOnScreenGLSurface( + ctx: GrDirectContext, + width: number, + height: number, + colorSpace: ColorSpace, + sampleCount?: number, + stencil?: number + ): Surface | null; + + /** + * Creates a context that operates over the given WebGPU Device. + * @param device + */ + MakeGPUDeviceContext(device: GPUDevice): WebGPUDeviceContext | null; + + /** + * Creates a Surface that draws to the given GPU texture. + * @param ctx + * @param texture - A texture that was created on the GPU device associated with `ctx`. + * @param width - Width of the visible region in pixels. + * @param height - Height of the visible region in pixels. + * @param colorSpace + */ + MakeGPUTextureSurface( + ctx: WebGPUDeviceContext, + texture: GPUTexture, + width: number, + height: number, + colorSpace: ColorSpace + ): Surface | null; + + /** + * Creates and configures a WebGPU context for the given canvas. + * @param ctx + * @param canvas + * @param opts + */ + MakeGPUCanvasContext( + ctx: WebGPUDeviceContext, + canvas: HTMLCanvasElement, + opts?: WebGPUCanvasOptions + ): WebGPUCanvasContext | null; + + /** + * Creates a Surface backed by the next available texture in the swapchain associated with the + * given WebGPU canvas context. The context must have been already successfully configured using + * the same GPUDevice associated with `ctx`. + * @param canvasContext - WebGPU context associated with the canvas. The canvas can either be an + * on-screen HTMLCanvasElement or an OffscreenCanvas. + * @param colorSpace + * @param width - width of the visible region. If not present, the canvas width from `canvasContext` + * is used. + * @param height - height of the visible region. If not present, the canvas width from `canvasContext` + * is used. + */ + MakeGPUCanvasSurface( + canvasContext: WebGPUCanvasContext, + colorSpace: ColorSpace, + width?: number, + height?: number + ): Surface | null; + + /** + * Returns a (non-visible) Surface on the GPU. It has the given dimensions and uses 8888 + * color depth and premultiplied alpha. See Surface.h for more details. + * @param ctx + * @param width + * @param height + */ + MakeRenderTarget( + ctx: GrDirectContext, + width: number, + height: number + ): Surface | null; + + /** + * Returns a (non-visible) Surface on the GPU. It has the settings provided by image info. + * See Surface.h for more details. + * @param ctx + * @param info + */ + MakeRenderTarget(ctx: GrDirectContext, info: ImageInfo): Surface | null; + + /** + * Returns a texture-backed image based on the content in src. It assumes the image is + * RGBA_8888, unpremul and SRGB. This image can be re-used across multiple surfaces. + * + * Not available for software-backed surfaces. + * @param src - CanvasKit will take ownership of the TextureSource and clean it up when + * the image is destroyed. + * @param info - If provided, will be used to determine the width/height/format of the + * source image. If not, sensible defaults will be used. + * @param srcIsPremul - set to true if the src data has premultiplied alpha. Otherwise, it will + * be assumed to be Unpremultiplied. Note: if this is true and info specifies + * Unpremul, Skia will not convert the src pixels first. + */ + MakeLazyImageFromTextureSource( + src: TextureSource, + info?: ImageInfo | PartialImageInfo, + srcIsPremul?: boolean + ): Image; + + /** + * Deletes the associated WebGLContext. Function not available on the CPU version. + * @param ctx + */ + deleteContext(ctx: WebGLContextHandle): void; + + /** + * Returns the max size of the global cache for bitmaps used by CanvasKit. + */ + getDecodeCacheLimitBytes(): number; + /** + * Returns the current size of the global cache for bitmaps used by CanvasKit. + */ + getDecodeCacheUsedBytes(): number; + + /** + * Sets the max size of the global cache for bitmaps used by CanvasKit. + * @param size - number of bytes that can be used to cache bitmaps. + */ + setDecodeCacheLimitBytes(size: number): void; + + /** + * Decodes the given bytes into an animated image. Returns null if the bytes were invalid. + * The passed in bytes will be copied into the WASM heap, so the caller can dispose of them. + * + * The returned AnimatedImage will be "pointing to" the first frame, i.e. currentFrameDuration + * and makeImageAtCurrentFrame will be referring to the first frame. + * @param bytes + */ + MakeAnimatedImageFromEncoded( + bytes: Uint8Array | ArrayBuffer + ): AnimatedImage | null; + + /** + * Returns an emulated Canvas2D of the given size. + * @param width + * @param height + */ + MakeCanvas(width: number, height: number): EmulatedCanvas2D; + + /** + * Returns an image with the given pixel data and format. + * Note that we will always make a copy of the pixel data, because of inconsistencies in + * behavior between GPU and CPU (i.e. the pixel data will be turned into a GPU texture and + * not modifiable after creation). + * + * @param info + * @param bytes - bytes representing the pixel data. + * @param bytesPerRow + */ + MakeImage( + info: ImageInfo, + bytes: number[] | Uint8Array | Uint8ClampedArray, + bytesPerRow: number + ): Image | null; + + /** + * Return an Image backed by the encoded data, but attempt to defer decoding until the image + * is actually used/drawn. This deferral allows the system to cache the result, either on the + * CPU or on the GPU, depending on where the image is drawn. + * This decoding uses the codecs that have been compiled into CanvasKit. If the bytes are + * invalid (or an unrecognized codec), null will be returned. See Image.h for more details. + * @param bytes + */ + MakeImageFromEncoded(bytes: Uint8Array | ArrayBuffer): Image | null; + + /** + * Returns an Image with the data from the provided CanvasImageSource (e.g. ). This will + * use the browser's built in codecs, in that src will be drawn to a canvas and then readback + * and placed into an Image. + * @param src + */ + MakeImageFromCanvasImageSource(src: CanvasImageSource): Image; + + /** + * Returns an SkPicture which has been serialized previously to the given bytes. + * @param bytes + */ + MakePicture(bytes: Uint8Array | ArrayBuffer): SkPicture | null; + + /** + * Returns an Vertices based on the given positions and optional parameters. + * See SkVertices.h (especially the Builder) for more details. + * @param mode + * @param positions + * @param textureCoordinates + * @param colors - either a list of int colors or a flattened color array. + * @param indices + * @param isVolatile + */ + MakeVertices( + mode: VertexMode, + positions: InputFlattenedPointArray, + textureCoordinates?: InputFlattenedPointArray | null, + colors?: Float32Array | ColorIntArray | null, + indices?: number[] | null, + isVolatile?: boolean + ): Vertices; + + /** + * Returns a Skottie animation built from the provided json string. + * Requires that Skottie be compiled into CanvasKit. + * Note: this animation will not be able to display text or images. + * @param json + */ + MakeAnimation(json: string): SkottieAnimation; + + /** + * Returns a managed Skottie animation built from the provided json string and assets. + * Requires that Skottie be compiled into CanvasKit. + * @param json + * @param assets - a dictionary of named blobs: { key: ArrayBuffer, ... } + * @param filterPrefix - an optional string acting as a name filter for selecting "interesting" + * Lottie properties (surfaced in the embedded player controls) + * @param soundMap - an optional mapping of sound identifiers (strings) to AudioPlayers. + * Only needed if the animation supports sound. + */ + MakeManagedAnimation( + json: string, + assets?: Record, + filterPrefix?: string, + soundMap?: SoundMap + ): ManagedSkottieAnimation; + + // Constructors, i.e. things made with `new CanvasKit.Foo()`; + readonly ImageData: ImageDataConstructor; + readonly ParagraphStyle: ParagraphStyleConstructor; + readonly ContourMeasureIter: ContourMeasureIterConstructor; + readonly Font: FontConstructor; + readonly Paint: DefaultConstructor; + readonly Path: PathConstructorAndFactory; + readonly PictureRecorder: DefaultConstructor; + readonly TextStyle: TextStyleConstructor; + readonly SlottableTextProperty: SlottableTextPropertyConstructor; + + // Factories, i.e. things made with CanvasKit.Foo.MakeTurboEncabulator() + readonly ParagraphBuilder: ParagraphBuilderFactory; + readonly Blender: BlenderFactory; + readonly ColorFilter: ColorFilterFactory; + readonly FontCollection: FontCollectionFactory; + readonly FontMgr: FontMgrFactory; + readonly ImageFilter: ImageFilterFactory; + readonly MaskFilter: MaskFilterFactory; + readonly PathEffect: PathEffectFactory; + readonly RuntimeEffect: RuntimeEffectFactory; + readonly Shader: ShaderFactory; + readonly TextBlob: TextBlobFactory; + readonly Typeface: TypefaceFactory; + readonly TypefaceFontProvider: TypefaceFontProviderFactory; + + // Misc + readonly ColorMatrix: ColorMatrixHelpers; + readonly Matrix: Matrix3x3Helpers; + readonly M44: Matrix4x4Helpers; + readonly Vector: VectorHelpers; + + // Core Enums + readonly AlphaType: AlphaTypeEnumValues; + readonly BlendMode: BlendModeEnumValues; + readonly BlurStyle: BlurStyleEnumValues; + readonly ClipOp: ClipOpEnumValues; + readonly ColorChannel: ColorChannelEnumValues; + readonly ColorType: ColorTypeEnumValues; + readonly FillType: FillTypeEnumValues; + readonly FilterMode: FilterModeEnumValues; + readonly FontEdging: FontEdgingEnumValues; + readonly FontHinting: FontHintingEnumValues; + readonly GlyphRunFlags: GlyphRunFlagValues; + readonly ImageFormat: ImageFormatEnumValues; + readonly MipmapMode: MipmapModeEnumValues; + readonly PaintStyle: PaintStyleEnumValues; + readonly Path1DEffect: Path1DEffectStyleEnumValues; + readonly PathOp: PathOpEnumValues; + readonly PointMode: PointModeEnumValues; + readonly ColorSpace: ColorSpaceEnumValues; + readonly StrokeCap: StrokeCapEnumValues; + readonly StrokeJoin: StrokeJoinEnumValues; + readonly TileMode: TileModeEnumValues; + readonly VertexMode: VertexModeEnumValues; + readonly InputState: InputStateEnumValues; + readonly ModifierKey: ModifierKeyEnumValues; + + // Core Constants + readonly TRANSPARENT: Color; + readonly BLACK: Color; + readonly WHITE: Color; + readonly RED: Color; + readonly GREEN: Color; + readonly BLUE: Color; + readonly YELLOW: Color; + readonly CYAN: Color; + readonly MAGENTA: Color; + + readonly MOVE_VERB: number; + readonly LINE_VERB: number; + readonly QUAD_VERB: number; + readonly CONIC_VERB: number; + readonly CUBIC_VERB: number; + readonly CLOSE_VERB: number; + + readonly SaveLayerInitWithPrevious: SaveLayerFlag; + readonly SaveLayerF16ColorType: SaveLayerFlag; + + /** + * Use this shadow flag to indicate the occluding object is not opaque. Knowing that the + * occluder is opaque allows us to cull shadow geometry behind it and improve performance. + */ + readonly ShadowTransparentOccluder: number; + /** + * Use this shadow flag to not use analytic shadows. + */ + readonly ShadowGeometricOnly: number; + /** + * Use this shadow flag to indicate the light position represents a direction and light radius + * is blur radius at elevation 1. + */ + readonly ShadowDirectionalLight: number; + + readonly gpu?: boolean; // true if GPU code was compiled in + readonly managed_skottie?: boolean; // true if advanced (managed) Skottie code was compiled in + readonly rt_effect?: boolean; // true if RuntimeEffect was compiled in + readonly skottie?: boolean; // true if base Skottie code was compiled in + + // Paragraph Enums + readonly Affinity: AffinityEnumValues; + readonly DecorationStyle: DecorationStyleEnumValues; + readonly FontSlant: FontSlantEnumValues; + readonly FontWeight: FontWeightEnumValues; + readonly FontWidth: FontWidthEnumValues; + readonly PlaceholderAlignment: PlaceholderAlignmentEnumValues; + readonly RectHeightStyle: RectHeightStyleEnumValues; + readonly RectWidthStyle: RectWidthStyleEnumValues; + readonly TextAlign: TextAlignEnumValues; + readonly TextBaseline: TextBaselineEnumValues; + readonly TextDirection: TextDirectionEnumValues; + readonly TextHeightBehavior: TextHeightBehaviorEnumValues; + + // other enums + readonly VerticalTextAlign: VerticalTextAlignEnumValues; + readonly ResizePolicy: ResizePolicyEnumValues; + + // Paragraph Constants + readonly NoDecoration: number; + readonly UnderlineDecoration: number; + readonly OverlineDecoration: number; + readonly LineThroughDecoration: number; + + // Unicode enums + readonly CodeUnitFlags: CodeUnitFlagsEnumValues; +} + +export interface Camera { + /** a 3d point locating the camera. */ + eye: Vector3; + /** center of attention - the 3d point the camera is looking at. */ + coa: Vector3; + /** + * A unit vector pointing the cameras up direction. Note that using only eye and coa + * would leave the roll of the camera unspecified. + */ + up: Vector3; + /** near clipping plane distance */ + near: number; + /** far clipping plane distance */ + far: number; + /** field of view in radians */ + angle: AngleInRadians; +} + +/** + * CanvasKit is built with Emscripten and Embind. Embind adds the following methods to all objects + * that are exposed with it. + * This _type field is necessary for the TypeScript compiler to differentiate + * between opaque types such as Shader and ColorFilter. It doesn't exist at runtime. + */ +export interface EmbindObject { + _type: T; + delete(): void; + deleteLater(): void; + isAliasOf(other: any): boolean; + isDeleted(): boolean; +} + +/** + * Represents the set of enum values. + */ +export interface EmbindEnum { + readonly values: number[]; +} + +/** + * Represents a single member of an enum. + */ +export interface EmbindEnumEntity { + readonly value: number; +} + +export interface EmulatedCanvas2D { + /** + * Cleans up all resources associated with this emulated canvas. + */ + dispose(): void; + /** + * Decodes an image with the given bytes. + * @param bytes + */ + decodeImage(bytes: ArrayBuffer | Uint8Array): Image; + + /** + * Returns an emulated canvas2d context if type == '2d', null otherwise. + * @param type + */ + getContext(type: string): EmulatedCanvas2DContext | null; + + /** + * Loads the given font with the given descriptors. Emulates new FontFace(). + * @param bytes + * @param descriptors + */ + loadFont( + bytes: ArrayBuffer | Uint8Array, + descriptors: Record + ): void; + + /** + * Returns an new emulated Path2D object. + * @param str - an SVG string representing a path. + */ + makePath2D(str?: string): EmulatedPath2D; + + /** + * Returns the current canvas as a base64 encoded image string. + * @param codec - image/png by default; image/jpeg also supported. + * @param quality + */ + toDataURL(codec?: string, quality?: number): string; +} + +/** Part of the Canvas2D emulation code */ +export type EmulatedCanvas2DContext = CanvasRenderingContext2D; +export type EmulatedImageData = ImageData; +export type EmulatedPath2D = Path2D; + +export interface FontStyle { + weight?: FontWeight; + width?: FontWidth; + slant?: FontSlant; +} + +/** + * See GrDirectContext.h for more on this class. + */ +export interface GrDirectContext extends EmbindObject<"GrDirectContext"> { + getResourceCacheLimitBytes(): number; + getResourceCacheUsageBytes(): number; + releaseResourcesAndAbandonContext(): void; + setResourceCacheLimitBytes(bytes: number): void; +} + +/** + * Represents the context backed by a WebGPU device instance. + */ +export type WebGPUDeviceContext = GrDirectContext; + +/** + * Represents the canvas context and swapchain backed by a WebGPU device. + */ +export interface WebGPUCanvasContext { + /** + * A convenient way to draw multiple frames over the swapchain texture sequence associated with + * a canvas element. Each call internally constructs a new Surface that targets the current + * GPUTexture in swapchain. + * + * This requires an environment where a global function called requestAnimationFrame is + * available (e.g. on the web, not on Node). The internally created surface is flushed and + * destroyed automatically by this wrapper once the `drawFrame` callback returns. + * + * Users can call canvasContext.requestAnimationFrame in the callback function to + * draw multiple frames, e.g. of an animation. + */ + requestAnimationFrame(drawFrame: (_: Canvas) => void): void; +} + +/** + * The glyph and grapheme cluster information associated with a code point within + * a paragraph. + */ +export interface GlyphInfo { + /** + * The layout bounds of the grapheme cluster the code point belongs to, in + * the paragraph's coordinates. + * + * This width of the rect is horizontal advance of the grapheme cluster, + * the height of the rect is the line height when the grapheme cluster + * occupies a full line. + */ + graphemeLayoutBounds: Rect; + /** + * The left-closed-right-open UTF-16 range of the grapheme cluster the code + * point belongs to. + */ + graphemeClusterTextRange: URange; + /** The writing direction of the grapheme cluster. */ + dir: TextDirection; + /** + * Whether the associated glyph points to an ellipsis added by the text + * layout library. + * + * The text layout library truncates the lines that exceed the specified + * max line number, and may add an ellipsis to replace the last few code + * points near the logical end of the last visible line. If True, this object + * marks the logical end of the list of GlyphInfo objects that are + * retrievable from the text layout library. + */ + isEllipsis: boolean; +} + +/** + * See Metrics.h for more on this struct. + */ +export interface LineMetrics { + /** The index in the text buffer the line begins. */ + startIndex: number; + /** The index in the text buffer the line ends. */ + endIndex: number; + endExcludingWhitespaces: number; + endIncludingNewline: number; + /** True if the line ends in a hard break (e.g. newline) */ + isHardBreak: boolean; + /** + * The final computed ascent for the line. This can be impacted by + * the strut, height, scaling, as well as outlying runs that are very tall. + */ + ascent: number; + /** + * The final computed descent for the line. This can be impacted by + * the strut, height, scaling, as well as outlying runs that are very tall. + */ + descent: number; + /** round(ascent + descent) */ + height: number; + /** width of the line */ + width: number; + /** The left edge of the line. The right edge can be obtained with `left + width` */ + left: number; + /** The y position of the baseline for this line from the top of the paragraph. */ + baseline: number; + /** Zero indexed line number. */ + lineNumber: number; +} + +export interface Range { + first: number; + last: number; +} + +/** + * Information for a run of shaped text. See Paragraph.getShapedLines() + * + * Notes: + * positions is documented as Float32, but it holds twice as many as you expect, and they + * are treated logically as pairs of floats: {x0, y0}, {x1, y1}, ... for each glyph. + * + * positions and offsets arrays have 1 extra slot (actually 2 for positions) + * to describe the location "after" the last glyph in the glyphs array. + */ +export interface GlyphRun { + typeface: Typeface; // currently set to null (temporary) + size: number; + fakeBold: boolean; + fakeItalic: boolean; + + glyphs: Uint16Array; + positions: Float32Array; // alternating x0, y0, x1, y1, ... + offsets: Uint32Array; + flags: number; // see GlyphRunFlags +} + +/** + * Information for a paragraph of text. See Paragraph.getShapedLines() + */ +export interface ShapedLine { + textRange: Range; // first and last character offsets for the line (derived from runs[]) + top: number; // top y-coordinate for the line + bottom: number; // bottom y-coordinate for the line + baseline: number; // baseline y-coordinate for the line + runs: GlyphRun[]; // array of GlyphRun objects for the line +} + +/** + * Input to ShapeText(..., FontBlock[], ...); + */ +export interface FontBlock { + length: number; // number of text codepoints this block is applied to + + typeface: Typeface; + size: number; + fakeBold: boolean; + fakeItalic: boolean; +} + +/** + * This object is a wrapper around a pointer to some memory on the WASM heap. The type of the + * pointer was determined at creation time. + */ +export interface MallocObj { + /** + * The number of objects this pointer refers to. + */ + readonly length: number; + /** + * The "pointer" into the WASM memory. Should be fixed over the lifetime of the object. + */ + readonly byteOffset: number; + /** + * Return a read/write view into a subset of the memory. Do not cache the TypedArray this + * returns, it may be invalidated if the WASM heap is resized. This is the same as calling + * .toTypedArray().subarray() except the returned TypedArray can also be passed into an API + * and not cause an additional copy. + */ + subarray(start: number, end: number): TypedArray; + /** + * Return a read/write view of the memory. Do not cache the TypedArray this returns, it may be + * invalidated if the WASM heap is resized. If this TypedArray is passed into a CanvasKit API, + * it will not be copied again, only the pointer will be re-used. + */ + toTypedArray(): TypedArray; +} + +/** + * This represents a subset of an animation's duration. + */ +export interface AnimationMarker { + name: string; + t0: number; // 0.0 to 1.0 + t1: number; // 0.0 to 1.0 +} + +/** + * This object maintains a single audio layer during skottie playback + */ +export interface AudioPlayer { + /** + * Playback control callback, emitted for each corresponding Animation::seek(). + * + * Will seek to time t (seconds) relative to the layer's timeline origin. + * Negative t values are used to signal off state (stop playback outside layer span). + */ + seek(t: number): void; +} + +/** + * Mapping of sound names (strings) to AudioPlayers + */ +export interface SoundMap { + /** + * Returns AudioPlayer for a certain audio layer + * @param key string identifier, name of audio file the desired AudioPlayer manages + */ + getPlayer(key: string): AudioPlayer; +} + +/** + * Named color property. + */ +export interface ColorProperty { + /** + * Property identifier, usually the node name. + */ + key: string; + /** + * Property value (RGBA, 255-based). + */ + value: ColorInt; +} + +/** + * Named opacity property. + */ +export interface OpacityProperty { + /** + * Property identifier, usually the node name. + */ + key: string; + /** + * Property value (0..100). + */ + value: number; +} + +/** + * Text property value. + */ +export interface TextValue { + /** + * The text string payload. + */ + text: string; + /** + * Font size. + */ + size: number; +} + +/** + * Named text property. + */ +export interface TextProperty { + /** + * Property identifier, usually the node name. + */ + key: string; + /** + * Property value. + */ + value: TextValue; +} + +/** + * Transform property value. Maps to AE styled transform. + */ +export interface TransformValue { + /** + * Anchor point for transform. x and y value. + */ + anchor: Point; + /** + * Position of transform. x and y value. + */ + position: Point; + /** + * Scale of transform. x and y value. + */ + scale: Vector2; + /** + * Rotation of transform in degrees. + */ + rotation: number; + /** + * Skew to apply during transform. + */ + skew: number; + /** + * Direction of skew in degrees. + */ + skew_axis: number; +} + +/** + * Named transform property for Skottie property observer. + */ +export interface TransformProperty { + /** + * Property identifier, usually the node name. + */ + key: string; + /** + * Property value. + */ + value: TransformValue; +} + +/** + * Collection of slot IDs sorted by value type + */ +export interface SlotInfo { + colorSlotIDs: string[]; + scalarSlotIDs: string[]; + vec2SlotIDs: string[]; + imageSlotIDs: string[]; + textSlotIDs: string[]; +} + +/** + * Text property for ManagedAnimation's slot support + */ +export interface SlottableTextProperty { + typeface?: Typeface; + text?: string; + + textSize?: number; + minTextSize?: number; + maxTextSize?: number; + strokeWidth?: number; + lineHeight?: number; + lineShift?: number; + ascent?: number; + maxLines?: number; + + horizAlign?: TextAlignEnumValues; + vertAlign?: VerticalTextAlignEnumValues; + strokeJoin?: StrokeJoinEnumValues; + direction?: TextDirectionEnumValues; + linebreak?: LineBreakTypeEnumValues; + resize?: ResizePolicyEnumValues; + + boundingBox?: InputRect; + fillColor?: InputColor; + strokeColor?: InputColor; +} + +export interface ManagedSkottieAnimation extends SkottieAnimation { + setColor(key: string, color: InputColor): boolean; + setOpacity(key: string, opacity: number): boolean; + setText(key: string, text: string, size: number): boolean; + setTransform( + key: string, + anchor: InputPoint, + position: InputPoint, + scale: InputVector2, + rotation: number, + skew: number, + skew_axis: number + ): boolean; + getMarkers(): AnimationMarker[]; + getColorProps(): ColorProperty[]; + getOpacityProps(): OpacityProperty[]; + getTextProps(): TextProperty[]; + getTransformProps(): TransformProperty[]; + + // Slots in Lottie were exposed with bodymovin version 5.11.0 + // Properties tracked under the Essential Graphics window in AE will be "slotted". These slots + // can be observed and editted live like with the other get/set tools. The slot id passed in + // must match the name of the property in the Essential Graphics window. Property Groups support + // one-to-many relationships. + getSlotInfo(): SlotInfo; + + setColorSlot(key: string, color: InputColor): boolean; + setScalarSlot(key: string, scalar: number): boolean; + setVec2Slot(key: string, vec2: InputVector2): boolean; + setTextSlot(key: string, text: SlottableTextProperty): boolean; + setImageSlot(key: string, assetName: string): boolean; + + getColorSlot(key: string): Color | null; + getScalarSlot(key: string): number | null; + getVec2Slot(key: string): Vector2 | null; + getTextSlot(key: string): SlottableTextProperty | null; + + // Attach a WYSIWYG editor to the text layer identified by 'id' and 'index' (multiple layers + // can be grouped with the same ID). + // Other layers with the same ID are attached as dependents, and updated on the fly as the + // edited layer changes. + attachEditor(id: string, index: number): boolean; + + // Enable/disable the current editor. + enableEditor(enable: boolean): void; + + // Send key events to the active editor. + dispatchEditorKey(key: string): boolean; + + // Send pointer events to the active editor, in canvas coordinates. + dispatchEditorPointer( + x: number, + y: number, + state: InputState, + modifier: ModifierKey + ): boolean; + + // Adjust the relative cursor weight (default: 1). + setEditorCursorWeight(w: number): void; +} + +/** + * See Paragraph.h for more information on this class. This is only available if Paragraph has + * been compiled in. + */ +export interface Paragraph extends EmbindObject<"Paragraph"> { + didExceedMaxLines(): boolean; + getAlphabeticBaseline(): number; + + /** + * Returns the index of the glyph that corresponds to the provided coordinate, + * with the top left corner as the origin, and +y direction as down. + */ + getGlyphPositionAtCoordinate(dx: number, dy: number): PositionWithAffinity; + /** + * Returns the information associated with the closest glyph at the specified + * paragraph coordinate, or null if the paragraph is empty. + */ + getClosestGlyphInfoAtCoordinate(dx: number, dy: number): GlyphInfo | null; + /** + * Returns the information associated with the glyph at the specified UTF-16 + * offset within the paragraph's visible lines, or null if the index is out + * of bounds, or points to a codepoint that is logically after the last + * visible codepoint. + */ + getGlyphInfoAt(index: number): GlyphInfo | null; + + getHeight(): number; + getIdeographicBaseline(): number; + /** + * Returns the line number of the line that contains the specified UTF-16 + * offset within the paragraph, or -1 if the index is out of bounds, or + * points to a codepoint that is logically after the last visible codepoint. + */ + getLineNumberAt(index: number): number; + getLineMetrics(): LineMetrics[]; + /** + * Returns the LineMetrics of the line at the specified line number, or null + * if the line number is out of bounds, or is larger than or equal to the + * specified max line number. + */ + getLineMetricsAt(lineNumber: number): LineMetrics | null; + getLongestLine(): number; + getMaxIntrinsicWidth(): number; + getMaxWidth(): number; + getMinIntrinsicWidth(): number; + /** + * Returns the total number of visible lines in the paragraph. + */ + getNumberOfLines(): number; + getRectsForPlaceholders(): RectWithDirection[]; + + /** + * Returns bounding boxes that enclose all text in the range of glpyh indexes [start, end). + * @param start + * @param end + * @param hStyle + * @param wStyle + */ + getRectsForRange( + start: number, + end: number, + hStyle: RectHeightStyle, + wStyle: RectWidthStyle + ): RectWithDirection[]; + + /** + * Finds the first and last glyphs that define a word containing the glyph at index offset. + * @param offset + */ + getWordBoundary(offset: number): URange; + + /** + * Returns an array of ShapedLine objects, describing the paragraph. + */ + getShapedLines(): ShapedLine[]; + + /** + * Lays out the text in the paragraph so it is wrapped to the given width. + * @param width + */ + layout(width: number): void; + + /** + * When called after shaping, returns the glyph IDs which were not matched + * by any of the provided fonts. + */ + unresolvedCodepoints(): number[]; +} + +export interface ParagraphBuilder extends EmbindObject<"ParagraphBuilder"> { + /** + * Pushes the information required to leave an open space. + * @param width + * @param height + * @param alignment + * @param baseline + * @param offset + */ + addPlaceholder( + width?: number, + height?: number, + alignment?: PlaceholderAlignment, + baseline?: TextBaseline, + offset?: number + ): void; + + /** + * Adds text to the builder. Forms the proper runs to use the upper-most style + * on the style_stack. + * @param str + */ + addText(str: string): void; + + /** + * Returns a Paragraph object that can be used to be layout and paint the text to an + * Canvas. + */ + build(): Paragraph; + + /** + * @param words is an array of word edges (starting or ending). You can + * pass 2 elements (0 as a start of the entire text and text.size as the + * end). This information is only needed for a specific API method getWords. + * + * The indices are expected to be relative to the UTF-8 representation of + * the text. + */ + setWordsUtf8(words: InputWords): void; + /** + * @param words is an array of word edges (starting or ending). You can + * pass 2 elements (0 as a start of the entire text and text.size as the + * end). This information is only needed for a specific API method getWords. + * + * The indices are expected to be relative to the UTF-16 representation of + * the text. + * + * The `Intl.Segmenter` API can be used as a source for this data. + */ + setWordsUtf16(words: InputWords): void; + + /** + * @param graphemes is an array of indexes in the input text that point + * to the start of each grapheme. + * + * The indices are expected to be relative to the UTF-8 representation of + * the text. + */ + setGraphemeBreaksUtf8(graphemes: InputGraphemes): void; + /** + * @param graphemes is an array of indexes in the input text that point + * to the start of each grapheme. + * + * The indices are expected to be relative to the UTF-16 representation of + * the text. + * + * The `Intl.Segmenter` API can be used as a source for this data. + */ + setGraphemeBreaksUtf16(graphemes: InputGraphemes): void; + + /** + * @param lineBreaks is an array of unsigned integers that should be + * treated as pairs (index, break type) that point to the places of possible + * line breaking if needed. It should include 0 as the first element. + * Break type == 0 means soft break, break type == 1 is a hard break. + * + * The indices are expected to be relative to the UTF-8 representation of + * the text. + */ + setLineBreaksUtf8(lineBreaks: InputLineBreaks): void; + /** + * @param lineBreaks is an array of unsigned integers that should be + * treated as pairs (index, break type) that point to the places of possible + * line breaking if needed. It should include 0 as the first element. + * Break type == 0 means soft break, break type == 1 is a hard break. + * + * The indices are expected to be relative to the UTF-16 representation of + * the text. + * + * Chrome's `v8BreakIterator` API can be used as a source for this data. + */ + setLineBreaksUtf16(lineBreaks: InputLineBreaks): void; + + /** + * Returns the entire Paragraph text (which is useful in case that text + * was produced as a set of addText calls). + */ + getText(): string; + + /** + * Remove a style from the stack. Useful to apply different styles to chunks + * of text such as bolding. + */ + pop(): void; + + /** + * Push a style to the stack. The corresponding text added with addText will + * use the top-most style. + * @param text + */ + pushStyle(text: TextStyle): void; + + /** + * Pushes a TextStyle using paints instead of colors for foreground and background. + * @param textStyle + * @param fg + * @param bg + */ + pushPaintStyle(textStyle: TextStyle, fg: Paint, bg: Paint): void; + + /** + * Resets this builder to its initial state, discarding any text, styles, placeholders that have + * been added, but keeping the initial ParagraphStyle. + */ + reset(): void; +} + +export interface ParagraphStyle { + disableHinting?: boolean; + ellipsis?: string; + heightMultiplier?: number; + maxLines?: number; + replaceTabCharacters?: boolean; + strutStyle?: StrutStyle; + textAlign?: TextAlign; + textDirection?: TextDirection; + textHeightBehavior?: TextHeightBehavior; + textStyle?: TextStyle; + applyRoundingHack?: boolean; +} + +export interface PositionWithAffinity { + pos: number; + affinity: Affinity; +} + +export interface SkSLUniform { + columns: number; + rows: number; + /** The index into the uniforms array that this uniform begins. */ + slot: number; + isInteger: boolean; +} + +/** + * See SkAnimatedImage.h for more information on this class. + */ +export interface AnimatedImage extends EmbindObject<"AnimatedImage"> { + /** + * Returns the length of the current frame in ms. + */ + currentFrameDuration(): number; + /** + * Decodes the next frame. Returns the length of that new frame in ms. + * Returns -1 when the animation is on the last frame. + */ + decodeNextFrame(): number; + + /** + * Return the total number of frames in the animation. + */ + getFrameCount(): number; + + /** + * Return the repetition count for this animation. + */ + getRepetitionCount(): number; + + /** + * Returns the possibly scaled height of the image. + */ + height(): number; + + /** + * Returns a still image of the current frame or null if there is no current frame. + */ + makeImageAtCurrentFrame(): Image | null; + + /** + * Reset the animation to the beginning. + */ + reset(): void; + + /** + * Returns the possibly scaled width of the image. + */ + width(): number; +} + +/** + * See SkBlender.h for more on this class. The objects are opaque. + */ +export type Blender = EmbindObject<"Blender">; + +/** + * See SkCanvas.h for more information on this class. + */ +export interface Canvas extends EmbindObject<"Canvas"> { + /** + * Fills the current clip with the given color using Src BlendMode. + * This has the effect of replacing all pixels contained by clip with color. + * @param color + */ + clear(color: InputColor): void; + + /** + * Replaces clip with the intersection or difference of the current clip and path, + * with an aliased or anti-aliased clip edge. + * @param path + * @param op + * @param doAntiAlias + */ + clipPath(path: Path, op: ClipOp, doAntiAlias: boolean): void; + + /** + * Replaces clip with the intersection or difference of the current clip and rect, + * with an aliased or anti-aliased clip edge. + * @param rect + * @param op + * @param doAntiAlias + */ + clipRect(rect: InputRect, op: ClipOp, doAntiAlias: boolean): void; + + /** + * Replaces clip with the intersection or difference of the current clip and rrect, + * with an aliased or anti-aliased clip edge. + * @param rrect + * @param op + * @param doAntiAlias + */ + clipRRect(rrect: InputRRect, op: ClipOp, doAntiAlias: boolean): void; + + /** + * Replaces current matrix with m premultiplied with the existing matrix. + * @param m + */ + concat(m: InputMatrix): void; + + /** + * Draws arc using clip, Matrix, and Paint paint. + * + * Arc is part of oval bounded by oval, sweeping from startAngle to startAngle plus + * sweepAngle. startAngle and sweepAngle are in degrees. + * @param oval - bounds of oval containing arc to draw + * @param startAngle - angle in degrees where arc begins + * @param sweepAngle - sweep angle in degrees; positive is clockwise + * @param useCenter - if true, include the center of the oval + * @param paint + */ + drawArc( + oval: InputRect, + startAngle: AngleInDegrees, + sweepAngle: AngleInDegrees, + useCenter: boolean, + paint: Paint + ): void; + + /** + * Draws a set of sprites from atlas, using clip, Matrix, and optional Paint paint. + * @param atlas - Image containing sprites + * @param srcRects - Rect locations of sprites in atlas + * @param dstXforms - RSXform mappings for sprites in atlas + * @param paint + * @param blendMode - BlendMode combining colors and sprites + * @param colors - If provided, will be blended with sprite using blendMode. + * @param sampling - Specifies sampling options. If null, bilinear is used. + */ + drawAtlas( + atlas: Image, + srcRects: InputFlattenedRectangleArray, + dstXforms: InputFlattenedRSXFormArray, + paint: Paint, + blendMode?: BlendMode | null, + colors?: ColorIntArray | null, + sampling?: CubicResampler | FilterOptions + ): void; + + /** + * Draws a circle at (cx, cy) with the given radius. + * @param cx + * @param cy + * @param radius + * @param paint + */ + drawCircle(cx: number, cy: number, radius: number, paint: Paint): void; + + /** + * Fills clip with the given color. + * @param color + * @param blendMode - defaults to SrcOver. + */ + drawColor(color: InputColor, blendMode?: BlendMode): void; + + /** + * Fills clip with the given color. + * @param r - red value (typically from 0 to 1.0). + * @param g - green value (typically from 0 to 1.0). + * @param b - blue value (typically from 0 to 1.0). + * @param a - alpha value, range 0 to 1.0 (1.0 is opaque). + * @param blendMode - defaults to SrcOver. + */ + drawColorComponents( + r: number, + g: number, + b: number, + a: number, + blendMode?: BlendMode + ): void; + + /** + * Fills clip with the given color. + * @param color + * @param blendMode - defaults to SrcOver. + */ + drawColorInt(color: ColorInt, blendMode?: BlendMode): void; + + /** + * Draws RRect outer and inner using clip, Matrix, and Paint paint. + * outer must contain inner or the drawing is undefined. + * @param outer + * @param inner + * @param paint + */ + drawDRRect(outer: InputRRect, inner: InputRRect, paint: Paint): void; + + /** + * Draws a run of glyphs, at corresponding positions, in a given font. + * @param glyphs the array of glyph IDs (Uint16TypedArray) + * @param positions the array of x,y floats to position each glyph + * @param x x-coordinate of the origin of the entire run + * @param y y-coordinate of the origin of the entire run + * @param font the font that contains the glyphs + * @param paint + */ + drawGlyphs( + glyphs: InputGlyphIDArray, + positions: InputFlattenedPointArray, + x: number, + y: number, + font: Font, + paint: Paint + ): void; + + /** + * Draws the given image with its top-left corner at (left, top) using the current clip, + * the current matrix, and optionally-provided paint. + * @param img + * @param left + * @param top + * @param paint + */ + drawImage(img: Image, left: number, top: number, paint?: Paint | null): void; + + /** + * Draws the given image with its top-left corner at (left, top) using the current clip, + * the current matrix. It will use the cubic sampling options B and C if necessary. + * @param img + * @param left + * @param top + * @param B - See CubicResampler in SkSamplingOptions.h for more information + * @param C - See CubicResampler in SkSamplingOptions.h for more information + * @param paint + */ + drawImageCubic( + img: Image, + left: number, + top: number, + B: number, + C: number, + paint?: Paint | null + ): void; + + /** + * Draws the given image with its top-left corner at (left, top) using the current clip, + * the current matrix. It will use the provided sampling options if necessary. + * @param img + * @param left + * @param top + * @param fm - The filter mode. + * @param mm - The mipmap mode. Note: for settings other than None, the image must have mipmaps + * calculated with makeCopyWithDefaultMipmaps; + * @param paint + */ + drawImageOptions( + img: Image, + left: number, + top: number, + fm: FilterMode, + mm: MipmapMode, + paint?: Paint | null + ): void; + + /** + * Draws the provided image stretched proportionally to fit into dst rectangle. + * The center rectangle divides the image into nine sections: four sides, four corners, and + * the center. + * @param img + * @param center + * @param dest + * @param filter - what technique to use when sampling the image + * @param paint + */ + drawImageNine( + img: Image, + center: InputIRect, + dest: InputRect, + filter: FilterMode, + paint?: Paint | null + ): void; + + /** + * Draws sub-rectangle src from provided image, scaled and translated to fill dst rectangle. + * @param img + * @param src + * @param dest + * @param paint + * @param fastSample - if false, will filter strictly within src. + */ + drawImageRect( + img: Image, + src: InputRect, + dest: InputRect, + paint: Paint, + fastSample?: boolean + ): void; + + /** + * Draws sub-rectangle src from provided image, scaled and translated to fill dst rectangle. + * It will use the cubic sampling options B and C if necessary. + * @param img + * @param src + * @param dest + * @param B - See CubicResampler in SkSamplingOptions.h for more information + * @param C - See CubicResampler in SkSamplingOptions.h for more information + * @param paint + */ + drawImageRectCubic( + img: Image, + src: InputRect, + dest: InputRect, + B: number, + C: number, + paint?: Paint | null + ): void; + + /** + * Draws sub-rectangle src from provided image, scaled and translated to fill dst rectangle. + * It will use the provided sampling options if necessary. + * @param img + * @param src + * @param dest + * @param fm - The filter mode. + * @param mm - The mipmap mode. Note: for settings other than None, the image must have mipmaps + * calculated with makeCopyWithDefaultMipmaps; + * @param paint + */ + drawImageRectOptions( + img: Image, + src: InputRect, + dest: InputRect, + fm: FilterMode, + mm: MipmapMode, + paint?: Paint | null + ): void; + + /** + * Draws line segment from (x0, y0) to (x1, y1) using the current clip, current matrix, + * and the provided paint. + * @param x0 + * @param y0 + * @param x1 + * @param y1 + * @param paint + */ + drawLine(x0: number, y0: number, x1: number, y1: number, paint: Paint): void; + + /** + * Draws an oval bounded by the given rectangle using the current clip, current matrix, + * and the provided paint. + * @param oval + * @param paint + */ + drawOval(oval: InputRect, paint: Paint): void; + + /** + * Fills clip with the given paint. + * @param paint + */ + drawPaint(paint: Paint): void; + + /** + * Draws the given Paragraph at the provided coordinates. + * Requires the Paragraph code to be compiled in. + * @param p + * @param x + * @param y + */ + drawParagraph(p: Paragraph, x: number, y: number): void; + + /** + * Draws the given path using the current clip, current matrix, and the provided paint. + * @param path + * @param paint + */ + drawPath(path: Path, paint: Paint): void; + + /** + * Draws a cubic patch defined by 12 control points [top, right, bottom, left] with optional + * colors and shader-coordinates [4] specifed for each corner [top-left, top-right, bottom-right, bottom-left] + * @param cubics 12 points : 4 connected cubics specifying the boundary of the patch + * @param colors optional colors interpolated across the patch + * @param texs optional shader coordinates interpolated across the patch + * @param mode Specifies how shader and colors blend (if both are specified) + * @param paint + */ + drawPatch( + cubics: InputFlattenedPointArray, + colors?: ColorIntArray | Color[] | null, + texs?: InputFlattenedPointArray | null, + mode?: BlendMode | null, + paint?: Paint + ): void; + + /** + * Draws the given picture using the current clip, current matrix, and the provided paint. + * @param skp + */ + drawPicture(skp: SkPicture): void; + + /** + * Draws the given points using the current clip, current matrix, and the provided paint. + * + * See Canvas.h for more on the mode and its interaction with paint. + * @param mode + * @param points + * @param paint + */ + drawPoints( + mode: PointMode, + points: InputFlattenedPointArray, + paint: Paint + ): void; + + /** + * Draws the given rectangle using the current clip, current matrix, and the provided paint. + * @param rect + * @param paint + */ + drawRect(rect: InputRect, paint: Paint): void; + + /** + * Draws the given rectangle using the current clip, current matrix, and the provided paint. + * @param left + * @param top + * @param right + * @param bottom + * @param paint + */ + drawRect4f( + left: number, + top: number, + right: number, + bottom: number, + paint: Paint + ): void; + + /** + * Draws the given rectangle with rounded corners using the current clip, current matrix, + * and the provided paint. + * @param rrect + * @param paint + */ + drawRRect(rrect: InputRRect, paint: Paint): void; + + /** + * Draw an offset spot shadow and outlining ambient shadow for the given path using a disc + * light. See SkShadowUtils.h for more details + * @param path - The occluder used to generate the shadows. + * @param zPlaneParams - Values for the plane function which returns the Z offset of the + * occluder from the canvas based on local x and y values (the current + * matrix is not applied). + * @param lightPos - The 3D position of the light relative to the canvas plane. This is + * independent of the canvas's current matrix. + * @param lightRadius - The radius of the disc light. + * @param ambientColor - The color of the ambient shadow. + * @param spotColor - The color of the spot shadow. + * @param flags - See SkShadowUtils.h; 0 means use default options. + */ + drawShadow( + path: Path, + zPlaneParams: InputVector3, + lightPos: InputVector3, + lightRadius: number, + ambientColor: InputColor, + spotColor: InputColor, + flags: number + ): void; + + /** + * Draw the given text at the location (x, y) using the provided paint and font. The text will + * be drawn as is; no shaping, left-to-right, etc. + * @param str + * @param x + * @param y + * @param paint + * @param font + */ + drawText(str: string, x: number, y: number, paint: Paint, font: Font): void; + + /** + * Draws the given TextBlob at (x, y) using the current clip, current matrix, and the + * provided paint. Reminder that the fonts used to draw TextBlob are part of the blob. + * @param blob + * @param x + * @param y + * @param paint + */ + drawTextBlob(blob: TextBlob, x: number, y: number, paint: Paint): void; + + /** + * Draws the given vertices (a triangle mesh) using the current clip, current matrix, and the + * provided paint. + * If paint contains an Shader and vertices does not contain texCoords, the shader + * is mapped using the vertices' positions. + * If vertices colors are defined in vertices, and Paint paint contains Shader, + * BlendMode mode combines vertices colors with Shader. + * @param verts + * @param mode + * @param paint + */ + drawVertices(verts: Vertices, mode: BlendMode, paint: Paint): void; + + /** + * Returns the bounds of clip, unaffected by the canvas's matrix. + * If the clip is empty, all four integers in the returned rectangle will equal zero. + * + * @param output - if provided, the results will be copied into the given array instead of + * allocating a new one. + */ + getDeviceClipBounds(output?: IRect): IRect; + + /** + * Returns true if the given rect, transformed by the current canvas + * transform, can be quickly determined to fall entirely outside the clip. + */ + quickReject(rect: InputRect): boolean; + + /** + * Returns the current transform from local coordinates to the 'device', which for most + * purposes means pixels. + */ + getLocalToDevice(): Matrix4x4; + + /** + * Returns the number of saved states, each containing: Matrix and clip. + * Equals the number of save() calls less the number of restore() calls plus one. + * The save count of a new canvas is one. + */ + getSaveCount(): number; + + /** + * Legacy version of getLocalToDevice(), which strips away any Z information, and + * just returns a 3x3 version. + */ + getTotalMatrix(): number[]; + + /** + * Creates Surface matching info and props, and associates it with Canvas. + * Returns null if no match found. + * @param info + */ + makeSurface(info: ImageInfo): Surface | null; + + /** + * Returns a TypedArray containing the pixels reading starting at (srcX, srcY) and does not + * exceed the size indicated by imageInfo. See SkCanvas.h for more on the caveats. + * + * If dest is not provided, we allocate memory equal to the provided height * the provided + * bytesPerRow to fill the data with. + * + * This is generally a very expensive call for the GPU backend. + * + * @param srcX + * @param srcY + * @param imageInfo - describes the destination format of the pixels. + * @param dest - If provided, the pixels will be copied into the allocated buffer allowing + * access to the pixels without allocating a new TypedArray. + * @param bytesPerRow - number of bytes per row. Must be provided if dest is set. This + * depends on destination ColorType. For example, it must be at least 4 * width for + * the 8888 color type. + * @returns a TypedArray appropriate for the specified ColorType. Note that 16 bit floats are + * not supported in JS, so that colorType corresponds to raw bytes Uint8Array. + */ + readPixels( + srcX: number, + srcY: number, + imageInfo: ImageInfo, + dest?: MallocObj, + bytesPerRow?: number + ): Float32Array | Uint8Array | null; + + /** + * Removes changes to the current matrix and clip since Canvas state was + * last saved. The state is removed from the stack. + * Does nothing if the stack is empty. + */ + restore(): void; + + /** + * Restores state to a previous stack value. + * @param saveCount + */ + restoreToCount(saveCount: number): void; + + /** + * Rotates the current matrix by the number of degrees. + * @param rot - angle of rotation in degrees. + * @param rx + * @param ry + */ + rotate(rot: AngleInDegrees, rx: number, ry: number): void; + + /** + * Saves the current matrix and clip and returns current height of the stack. + */ + save(): number; + + /** + * Saves Matrix and clip, and allocates a SkBitmap for subsequent drawing. + * Calling restore() discards changes to Matrix and clip, and draws the SkBitmap. + * It returns the height of the stack. + * See Canvas.h for more. + * @param paint + * @param bounds + * @param backdrop + * @param flags + * @param backdropFilterTileMode + */ + saveLayer( + paint?: Paint, + bounds?: InputRect | null, + backdrop?: ImageFilter | null, + flags?: SaveLayerFlag, + backdropFilterTileMode?: TileMode + ): number; + + /** + * Scales the current matrix by sx on the x-axis and sy on the y-axis. + * @param sx + * @param sy + */ + scale(sx: number, sy: number): void; + + /** + * Skews Matrix by sx on the x-axis and sy on the y-axis. A positive value of sx + * skews the drawing right as y-axis values increase; a positive value of sy skews + * the drawing down as x-axis values increase. + * @param sx + * @param sy + */ + skew(sx: number, sy: number): void; + + /** + * Translates Matrix by dx along the x-axis and dy along the y-axis. + * @param dx + * @param dy + */ + translate(dx: number, dy: number): void; + + /** + * Writes the given rectangle of pixels to the provided coordinates. The source pixels + * will be converted to the canvas's alphaType and colorType if they do not match. + * @param pixels + * @param srcWidth + * @param srcHeight + * @param destX + * @param destY + * @param alphaType - defaults to Unpremul + * @param colorType - defaults to RGBA_8888 + * @param colorSpace - defaults to SRGB + */ + writePixels( + pixels: Uint8Array | number[], + srcWidth: number, + srcHeight: number, + destX: number, + destY: number, + alphaType?: AlphaType, + colorType?: ColorType, + colorSpace?: ColorSpace + ): boolean; +} + +/** + * See SkColorFilter.h for more on this class. The objects are opaque. + */ +export type ColorFilter = EmbindObject<"ColorFilter">; + +export interface ContourMeasureIter extends EmbindObject<"ContourMeasureIter"> { + /** + * Iterates through contours in path, returning a contour-measure object for each contour + * in the path. Returns null when it is done. + * + * See SkContourMeasure.h for more details. + */ + next(): ContourMeasure | null; +} + +export interface ContourMeasure extends EmbindObject<"ContourMeasure"> { + /** + * Returns the given position and tangent line for the distance on the given contour. + * The return value is 4 floats in this order: posX, posY, vecX, vecY. + * @param distance - will be pinned between 0 and length(). + * @param output - if provided, the four floats of the PosTan will be copied into this array + * instead of allocating a new one. + */ + getPosTan(distance: number, output?: PosTan): PosTan; + + /** + * Returns an Path representing the segement of this contour. + * @param startD - will be pinned between 0 and length() + * @param stopD - will be pinned between 0 and length() + * @param startWithMoveTo + */ + getSegment(startD: number, stopD: number, startWithMoveTo: boolean): Path; + + /** + * Returns true if the contour is closed. + */ + isClosed(): boolean; + + /** + * Returns the length of this contour. + */ + length(): number; +} + +export interface FontMetrics { + ascent: number; // suggested space above the baseline. < 0 + descent: number; // suggested space below the baseline. > 0 + leading: number; // suggested spacing between descent of previous line and ascent of next line. + bounds?: Rect; // smallest rect containing all glyphs (relative to 0,0) +} + +/** + * See SkFont.h for more on this class. + */ +export interface Font extends EmbindObject<"Font"> { + /** + * Returns the FontMetrics for this font. + */ + getMetrics(): FontMetrics; + + /** + * Retrieves the bounds for each glyph in glyphs. + * If paint is not null, its stroking, PathEffect, and MaskFilter fields are respected. + * These are returned as flattened rectangles. For each glyph, there will be 4 floats for + * left, top, right, bottom (relative to 0, 0) for that glyph. + * @param glyphs + * @param paint + * @param output - if provided, the results will be copied into this array. + */ + getGlyphBounds( + glyphs: InputGlyphIDArray, + paint?: Paint | null, + output?: Float32Array + ): Float32Array; + + /** + * Retrieves the glyph ids for each code point in the provided string. This call is passed to + * the typeface of this font. Note that glyph IDs are typeface-dependent; different faces + * may have different ids for the same code point. + * @param str + * @param numCodePoints - the number of code points in the string. Defaults to str.length. + * @param output - if provided, the results will be copied into this array. + */ + getGlyphIDs( + str: string, + numCodePoints?: number, + output?: GlyphIDArray + ): GlyphIDArray; + + /** + * Retrieves the advanceX measurements for each glyph. + * If paint is not null, its stroking, PathEffect, and MaskFilter fields are respected. + * One width per glyph is returned in the returned array. + * @param glyphs + * @param paint + * @param output - if provided, the results will be copied into this array. + */ + getGlyphWidths( + glyphs: InputGlyphIDArray, + paint?: Paint | null, + output?: Float32Array + ): Float32Array; + + /** + * Computes any intersections of a thick "line" and a run of positionsed glyphs. + * The thick line is represented as a top and bottom coordinate (positive for + * below the baseline, negative for above). If there are no intersections + * (e.g. if this is intended as an underline, and there are no "collisions") + * then the returned array will be empty. If there are intersections, the array + * will contain pairs of X coordinates [start, end] for each segment that + * intersected with a glyph. + * + * @param glyphs the glyphs to intersect with + * @param positions x,y coordinates (2 per glyph) for each glyph + * @param top top of the thick "line" to use for intersection testing + * @param bottom bottom of the thick "line" to use for intersection testing + * @return array of [start, end] x-coordinate pairs. Maybe be empty. + */ + getGlyphIntercepts( + glyphs: InputGlyphIDArray, + positions: Float32Array | number[], + top: number, + bottom: number + ): Float32Array; + + /** + * Returns text scale on x-axis. Default value is 1. + */ + getScaleX(): number; + + /** + * Returns text size in points. + */ + getSize(): number; + + /** + * Returns text skew on x-axis. Default value is zero. + */ + getSkewX(): number; + + /** + * Returns embolden effect for this font. Default value is false. + */ + isEmbolden(): boolean; + + /** + * Returns the Typeface set for this font. + */ + getTypeface(): Typeface | null; + + /** + * Requests, but does not require, that edge pixels draw opaque or with partial transparency. + * @param edging + */ + setEdging(edging: FontEdging): void; + + /** + * Requests, but does not require, to use bitmaps in fonts instead of outlines. + * @param embeddedBitmaps + */ + setEmbeddedBitmaps(embeddedBitmaps: boolean): void; + + /** + * Sets level of glyph outline adjustment. + * @param hinting + */ + setHinting(hinting: FontHinting): void; + + /** + * Requests, but does not require, linearly scalable font and glyph metrics. + * + * For outline fonts 'true' means font and glyph metrics should ignore hinting and rounding. + * Note that some bitmap formats may not be able to scale linearly and will ignore this flag. + * @param linearMetrics + */ + setLinearMetrics(linearMetrics: boolean): void; + + /** + * Sets the text scale on the x-axis. + * @param sx + */ + setScaleX(sx: number): void; + + /** + * Sets the text size in points on this font. + * @param points + */ + setSize(points: number): void; + + /** + * Sets the text-skew on the x axis for this font. + * @param sx + */ + setSkewX(sx: number): void; + + /** + * Set embolden effect for this font. + * @param embolden + */ + setEmbolden(embolden: boolean): void; + + /** + * Requests, but does not require, that glyphs respect sub-pixel positioning. + * @param subpixel + */ + setSubpixel(subpixel: boolean): void; + + /** + * Sets the typeface to use with this font. null means to clear the typeface and use the + * default one. + * @param face + */ + setTypeface(face: Typeface | null): void; +} + +/** + * See SkFontMgr.h for more details + */ +export interface FontMgr extends EmbindObject<"FontMgr"> { + /** + * Return the number of font families loaded in this manager. Useful for debugging. + */ + countFamilies(): number; + + /** + * Return the nth family name. Useful for debugging. + * @param index + */ + getFamilyName(index: number): string; + + /** + * Find the closest matching typeface to the specified familyName and style. + */ + matchFamilyStyle(name: string, style: FontStyle): Typeface; +} + +/** + * See SkImage.h for more information on this class. + */ +export interface Image extends EmbindObject<"Image"> { + /** + * Encodes this image's pixels to the specified format and returns them. Must be built with + * the specified codec. If the options are unspecified, sensible defaults will be + * chosen. + * @param fmt - PNG is the default value. + * @param quality - a value from 0 to 100; 100 is the least lossy. May be ignored. + */ + encodeToBytes(fmt?: EncodedImageFormat, quality?: number): Uint8Array | null; + + /** + * Returns the color space associated with this object. + * It is the user's responsibility to call delete() on this after it has been used. + */ + getColorSpace(): ColorSpace; + + /** + * Returns the width, height, colorType and alphaType associated with this image. + * Colorspace is separate so as to not accidentally leak that memory. + */ + getImageInfo(): PartialImageInfo; + + /** + * Return the height in pixels of the image. + */ + height(): number; + + /** + * Returns an Image with the same "base" pixels as the this image, but with mipmap levels + * automatically generated and attached. + */ + makeCopyWithDefaultMipmaps(): Image; + + /** + * Returns this image as a shader with the specified tiling. It will use cubic sampling. + * @param tx - tile mode in the x direction. + * @param ty - tile mode in the y direction. + * @param B - See CubicResampler in SkSamplingOptions.h for more information + * @param C - See CubicResampler in SkSamplingOptions.h for more information + * @param localMatrix + */ + makeShaderCubic( + tx: TileMode, + ty: TileMode, + B: number, + C: number, + localMatrix?: InputMatrix + ): Shader; + + /** + * Returns this image as a shader with the specified tiling. It will use cubic sampling. + * @param tx - tile mode in the x direction. + * @param ty - tile mode in the y direction. + * @param fm - The filter mode. + * @param mm - The mipmap mode. Note: for settings other than None, the image must have mipmaps + * calculated with makeCopyWithDefaultMipmaps; + * @param localMatrix + */ + makeShaderOptions( + tx: TileMode, + ty: TileMode, + fm: FilterMode, + mm: MipmapMode, + localMatrix?: InputMatrix + ): Shader; + + /** + * Returns a TypedArray containing the pixels reading starting at (srcX, srcY) and does not + * exceed the size indicated by imageInfo. See SkImage.h for more on the caveats. + * + * If dest is not provided, we allocate memory equal to the provided height * the provided + * bytesPerRow to fill the data with. + * + * @param srcX + * @param srcY + * @param imageInfo - describes the destination format of the pixels. + * @param dest - If provided, the pixels will be copied into the allocated buffer allowing + * access to the pixels without allocating a new TypedArray. + * @param bytesPerRow - number of bytes per row. Must be provided if dest is set. This + * depends on destination ColorType. For example, it must be at least 4 * width for + * the 8888 color type. + * @returns a TypedArray appropriate for the specified ColorType. Note that 16 bit floats are + * not supported in JS, so that colorType corresponds to raw bytes Uint8Array. + */ + readPixels( + srcX: number, + srcY: number, + imageInfo: ImageInfo, + dest?: MallocObj, + bytesPerRow?: number + ): Float32Array | Uint8Array | null; + + /** + * Return the width in pixels of the image. + */ + width(): number; +} + +/** + * See ImageFilter.h for more on this class. The objects are opaque. + */ +export interface ImageFilter extends EmbindObject<"ImageFilter"> { + /** + * Returns an IRect that is the updated bounds of inputRect after this + * filter has been applied. + * + * @param drawBounds - The local (pre-transformed) bounding box of the + * geometry being drawn _before_ the filter is applied. + * @param ctm - If provided, the current transform at the time the filter + * would be used. + * @param outputRect - If provided, the result will be output to this array + * rather than allocating a new one. + * @returns an IRect describing the updated bounds. + */ + getOutputBounds( + drawBounds: Rect, + ctm?: InputMatrix, + outputRect?: IRect + ): IRect; +} + +export interface ImageInfo { + alphaType: AlphaType; + colorSpace: ColorSpace; + colorType: ColorType; + height: number; + width: number; +} + +export interface PartialImageInfo { + alphaType: AlphaType; + colorType: ColorType; + height: number; + width: number; +} + +/* + * Specifies sampling with bicubic coefficients + */ +export interface CubicResampler { + B: number; // 0..1 + C: number; // 0..1 +} + +/** + * Specifies sampling using filter and mipmap options + */ +export interface FilterOptions { + filter: FilterMode; + mipmap?: MipmapMode; // defaults to None if not specified +} + +/** + * See SkMaskFilter.h for more on this class. The objects are opaque. + */ +export type MaskFilter = EmbindObject<"MaskFilter">; + +/** + * See SkPaint.h for more information on this class. + */ +export interface Paint extends EmbindObject<"Paint"> { + /** + * Returns a copy of this paint. + */ + copy(): Paint; + + /** + * Retrieves the alpha and RGB unpremultiplied. RGB are extended sRGB values + * (sRGB gamut, and encoded with the sRGB transfer function). + */ + getColor(): Color; + + /** + * Returns the geometry drawn at the beginning and end of strokes. + */ + getStrokeCap(): StrokeCap; + + /** + * Returns the geometry drawn at the corners of strokes. + */ + getStrokeJoin(): StrokeJoin; + + /** + * Returns the limit at which a sharp corner is drawn beveled. + */ + getStrokeMiter(): number; + + /** + * Returns the thickness of the pen used to outline the shape. + */ + getStrokeWidth(): number; + + /** + * Replaces alpha, leaving RGBA unchanged. 0 means fully transparent, 1.0 means opaque. + * @param alpha + */ + setAlphaf(alpha: number): void; + + /** + * Requests, but does not require, that edge pixels draw opaque or with + * partial transparency. + * @param aa + */ + setAntiAlias(aa: boolean): void; + + /** + * Sets the blend mode that is, the mode used to combine source color + * with destination color. + * @param mode + */ + setBlendMode(mode: BlendMode): void; + + /** + * Sets the current blender, increasing its refcnt, and if a blender is already + * present, decreasing that object's refcnt. + * + * * A nullptr blender signifies the default SrcOver behavior. + * + * * For convenience, you can call setBlendMode() if the blend effect can be expressed + * as one of those values. + * @param blender + */ + setBlender(blender: Blender): void; + + /** + * Sets alpha and RGB used when stroking and filling. The color is four floating + * point values, unpremultiplied. The color values are interpreted as being in + * the provided colorSpace. + * @param color + * @param colorSpace - defaults to sRGB + */ + setColor(color: InputColor, colorSpace?: ColorSpace): void; + + /** + * Sets alpha and RGB used when stroking and filling. The color is four floating + * point values, unpremultiplied. The color values are interpreted as being in + * the provided colorSpace. + * @param r + * @param g + * @param b + * @param a + * @param colorSpace - defaults to sRGB + */ + setColorComponents( + r: number, + g: number, + b: number, + a: number, + colorSpace?: ColorSpace + ): void; + + /** + * Sets the current color filter, replacing the existing one if there was one. + * @param filter + */ + setColorFilter(filter: ColorFilter | null): void; + + /** + * Sets the color used when stroking and filling. The color values are interpreted as being in + * the provided colorSpace. + * @param color + * @param colorSpace - defaults to sRGB. + */ + setColorInt(color: ColorInt, colorSpace?: ColorSpace): void; + + /** + * Requests, but does not require, to distribute color error. + * @param shouldDither + */ + setDither(shouldDither: boolean): void; + + /** + * Sets the current image filter, replacing the existing one if there was one. + * @param filter + */ + setImageFilter(filter: ImageFilter | null): void; + + /** + * Sets the current mask filter, replacing the existing one if there was one. + * @param filter + */ + setMaskFilter(filter: MaskFilter | null): void; + + /** + * Sets the current path effect, replacing the existing one if there was one. + * @param effect + */ + setPathEffect(effect: PathEffect | null): void; + + /** + * Sets the current shader, replacing the existing one if there was one. + * @param shader + */ + setShader(shader: Shader | null): void; + + /** + * Sets the geometry drawn at the beginning and end of strokes. + * @param cap + */ + setStrokeCap(cap: StrokeCap): void; + + /** + * Sets the geometry drawn at the corners of strokes. + * @param join + */ + setStrokeJoin(join: StrokeJoin): void; + + /** + * Sets the limit at which a sharp corner is drawn beveled. + * @param limit + */ + setStrokeMiter(limit: number): void; + + /** + * Sets the thickness of the pen used to outline the shape. + * @param width + */ + setStrokeWidth(width: number): void; + + /** + * Sets whether the geometry is filled or stroked. + * @param style + */ + setStyle(style: PaintStyle): void; +} + +/** + * See SkPath.h for more information on this class. + */ +export interface Path extends EmbindObject<"Path"> { + /** + * Appends arc to Path, as the start of new contour. Arc added is part of ellipse + * bounded by oval, from startAngle through sweepAngle. Both startAngle and + * sweepAngle are measured in degrees, where zero degrees is aligned with the + * positive x-axis, and positive sweeps extends arc clockwise. + * Returns the modified path for easier chaining. + * @param oval + * @param startAngle + * @param sweepAngle + */ + addArc( + oval: InputRect, + startAngle: AngleInDegrees, + sweepAngle: AngleInDegrees + ): Path; + + /** + * Adds circle centered at (x, y) of size radius to the path. + * Has no effect if radius is zero or negative. + * + * @param x center of circle + * @param y center of circle + * @param radius distance from center to edge + * @param isCCW - if the path should be drawn counter-clockwise or not + * @return reference to SkPath + */ + addCircle(x: number, y: number, r: number, isCCW?: boolean): Path; + + /** + * Adds oval to Path, appending kMove_Verb, four kConic_Verb, and kClose_Verb. + * Oval is upright ellipse bounded by Rect oval with radii equal to half oval width + * and half oval height. Oval begins at start and continues clockwise by default. + * Returns the modified path for easier chaining. + * @param oval + * @param isCCW - if the path should be drawn counter-clockwise or not + * @param startIndex - index of initial point of ellipse + */ + addOval(oval: InputRect, isCCW?: boolean, startIndex?: number): Path; + + /** + * Takes 1, 2, 7, or 10 required args, where the first arg is always the path. + * The last arg is an optional boolean and chooses between add or extend mode. + * The options for the remaining args are: + * - an array of 6 or 9 parameters (perspective is optional) + * - the 9 parameters of a full matrix or + * the 6 non-perspective params of a matrix. + * Returns the modified path for easier chaining (or null if params were incorrect). + * @param args + */ + addPath(...args: any[]): Path | null; + + /** + * Adds contour created from array of n points, adding (count - 1) line segments. + * Contour added starts at pts[0], then adds a line for every additional point + * in pts array. If close is true, appends kClose_Verb to Path, connecting + * pts[count - 1] and pts[0]. + * Returns the modified path for easier chaining. + * @param points + * @param close - if true, will add a line connecting last point to the first point. + */ + addPoly(points: InputFlattenedPointArray, close: boolean): Path; + + /** + * Adds Rect to Path, appending kMove_Verb, three kLine_Verb, and kClose_Verb, + * starting with top-left corner of Rect; followed by top-right, bottom-right, + * and bottom-left if isCCW is false; or followed by bottom-left, + * bottom-right, and top-right if isCCW is true. + * Returns the modified path for easier chaining. + * @param rect + * @param isCCW + */ + addRect(rect: InputRect, isCCW?: boolean): Path; + + /** + * Adds rrect to Path, creating a new closed contour. + * Returns the modified path for easier chaining. + * @param rrect + * @param isCCW + */ + addRRect(rrect: InputRRect, isCCW?: boolean): Path; + + /** + * Adds the given verbs and associated points/weights to the path. The process + * reads the first verb from verbs and then the appropriate number of points from the + * FlattenedPointArray (e.g. 2 points for moveTo, 4 points for quadTo, etc). If the verb is + * a conic, a weight will be read from the WeightList. + * Returns the modified path for easier chaining + * @param verbs - the verbs that create this path, in the order of being drawn. + * @param points - represents n points with 2n floats. + * @param weights - used if any of the verbs are conics, can be omitted otherwise. + */ + addVerbsPointsWeights( + verbs: VerbList, + points: InputFlattenedPointArray, + weights?: WeightList + ): Path; + + /** + * Adds an arc to this path, emulating the Canvas2D behavior. + * Returns the modified path for easier chaining. + * @param x + * @param y + * @param radius + * @param startAngle + * @param endAngle + * @param isCCW + */ + arc( + x: number, + y: number, + radius: number, + startAngle: AngleInRadians, + endAngle: AngleInRadians, + isCCW?: boolean + ): Path; + + /** + * Appends arc to Path. Arc added is part of ellipse + * bounded by oval, from startAngle through sweepAngle. Both startAngle and + * sweepAngle are measured in degrees, where zero degrees is aligned with the + * positive x-axis, and positive sweeps extends arc clockwise. + * Returns the modified path for easier chaining. + * @param oval + * @param startAngle + * @param endAngle + * @param forceMoveTo + */ + arcToOval( + oval: InputRect, + startAngle: AngleInDegrees, + endAngle: AngleInDegrees, + forceMoveTo: boolean + ): Path; + + /** + * Appends arc to Path. Arc is implemented by one or more conics weighted to + * describe part of oval with radii (rx, ry) rotated by xAxisRotate degrees. Arc + * curves from last Path Point to (x, y), choosing one of four possible routes: + * clockwise or counterclockwise, and smaller or larger. See SkPath.h for more details. + * Returns the modified path for easier chaining. + * @param rx + * @param ry + * @param xAxisRotate + * @param useSmallArc + * @param isCCW + * @param x + * @param y + */ + arcToRotated( + rx: number, + ry: number, + xAxisRotate: AngleInDegrees, + useSmallArc: boolean, + isCCW: boolean, + x: number, + y: number + ): Path; + + /** + * Appends arc to Path, after appending line if needed. Arc is implemented by conic + * weighted to describe part of circle. Arc is contained by tangent from + * last Path point to (x1, y1), and tangent from (x1, y1) to (x2, y2). Arc + * is part of circle sized to radius, positioned so it touches both tangent lines. + * Returns the modified path for easier chaining. + * @param x1 + * @param y1 + * @param x2 + * @param y2 + * @param radius + */ + arcToTangent( + x1: number, + y1: number, + x2: number, + y2: number, + radius: number + ): Path; + + /** + * Appends CLOSE_VERB to Path. A closed contour connects the first and last point + * with a line, forming a continuous loop. + * Returns the modified path for easier chaining. + */ + close(): Path; + + /** + * Returns minimum and maximum axes values of the lines and curves in Path. + * Returns (0, 0, 0, 0) if Path contains no points. + * Returned bounds width and height may be larger or smaller than area affected + * when Path is drawn. + * + * Behaves identically to getBounds() when Path contains + * only lines. If Path contains curves, computed bounds includes + * the maximum extent of the quad, conic, or cubic; is slower than getBounds(); + * and unlike getBounds(), does not cache the result. + * @param outputArray - if provided, the bounding box will be copied into this array instead of + * allocating a new one. + */ + computeTightBounds(outputArray?: Rect): Rect; + + /** + * Adds conic from last point towards (x1, y1), to (x2, y2), weighted by w. + * If Path is empty, or path is closed, the last point is set to (0, 0) + * before adding conic. + * Returns the modified path for easier chaining. + * @param x1 + * @param y1 + * @param x2 + * @param y2 + * @param w + */ + conicTo(x1: number, y1: number, x2: number, y2: number, w: number): Path; + + /** + * Returns true if the point (x, y) is contained by Path, taking into + * account FillType. + * @param x + * @param y + */ + contains(x: number, y: number): boolean; + + /** + * Returns a copy of this Path. + */ + copy(): Path; + + /** + * Returns the number of points in this path. Initially zero. + */ + countPoints(): number; + + /** + * Adds cubic from last point towards (x1, y1), then towards (x2, y2), ending at + * (x3, y3). If Path is empty, or path is closed, the last point is set to + * (0, 0) before adding cubic. + * @param cpx1 + * @param cpy1 + * @param cpx2 + * @param cpy2 + * @param x + * @param y + */ + cubicTo( + cpx1: number, + cpy1: number, + cpx2: number, + cpy2: number, + x: number, + y: number + ): Path; + + /** + * Changes this path to be the dashed version of itself. This is the same effect as creating + * a DashPathEffect and calling filterPath on this path. + * @param on + * @param off + * @param phase + */ + dash(on: number, off: number, phase: number): boolean; + + /** + * Returns true if other path is equal to this path. + * @param other + */ + equals(other: Path): boolean; + + /** + * Returns minimum and maximum axes values of Point array. + * Returns (0, 0, 0, 0) if Path contains no points. Returned bounds width and height may + * be larger or smaller than area affected when Path is drawn. + * @param outputArray - if provided, the bounding box will be copied into this array instead of + * allocating a new one. + */ + getBounds(outputArray?: Rect): Rect; + + /** + * Return the FillType for this path. + */ + getFillType(): FillType; + + /** + * Returns the Point at index in Point array. Valid range for index is + * 0 to countPoints() - 1. + * @param index + * @param outputArray - if provided, the point will be copied into this array instead of + * allocating a new one. + */ + getPoint(index: number, outputArray?: Point): Point; + + /** + * Returns true if there are no verbs in the path. + */ + isEmpty(): boolean; + + /** + * Returns true if the path is volatile; it will not be altered or discarded + * by the caller after it is drawn. Path by default have volatile set false, allowing + * Surface to attach a cache of data which speeds repeated drawing. If true, Surface + * may not speed repeated drawing. + */ + isVolatile(): boolean; + + /** + * Adds line from last point to (x, y). If Path is empty, or last path is closed, + * last point is set to (0, 0) before adding line. + * Returns the modified path for easier chaining. + * @param x + * @param y + */ + lineTo(x: number, y: number): Path; + + /** + * Returns a new path that covers the same area as the original path, but with the + * Winding FillType. This may re-draw some contours in the path as counter-clockwise + * instead of clockwise to achieve that effect. If such a transformation cannot + * be done, null is returned. + */ + makeAsWinding(): Path | null; + + /** + * Adds beginning of contour at the given point. + * Returns the modified path for easier chaining. + * @param x + * @param y + */ + moveTo(x: number, y: number): Path; + + /** + * Translates all the points in the path by dx, dy. + * Returns the modified path for easier chaining. + * @param dx + * @param dy + */ + offset(dx: number, dy: number): Path; + + /** + * Combines this path with the other path using the given PathOp. Returns false if the operation + * fails. + * @param other + * @param op + */ + op(other: Path, op: PathOp): boolean; + + /** + * Adds quad from last point towards (x1, y1), to (x2, y2). + * If Path is empty, or path is closed, last point is set to (0, 0) before adding quad. + * Returns the modified path for easier chaining. + * @param x1 + * @param y1 + * @param x2 + * @param y2 + */ + quadTo(x1: number, y1: number, x2: number, y2: number): Path; + + /** + * Relative version of arcToRotated. + * @param rx + * @param ry + * @param xAxisRotate + * @param useSmallArc + * @param isCCW + * @param dx + * @param dy + */ + rArcTo( + rx: number, + ry: number, + xAxisRotate: AngleInDegrees, + useSmallArc: boolean, + isCCW: boolean, + dx: number, + dy: number + ): Path; + + /** + * Relative version of conicTo. + * @param dx1 + * @param dy1 + * @param dx2 + * @param dy2 + * @param w + */ + rConicTo(dx1: number, dy1: number, dx2: number, dy2: number, w: number): Path; + + /** + * Relative version of cubicTo. + * @param cpx1 + * @param cpy1 + * @param cpx2 + * @param cpy2 + * @param x + * @param y + */ + rCubicTo( + cpx1: number, + cpy1: number, + cpx2: number, + cpy2: number, + x: number, + y: number + ): Path; + + /** + * Sets Path to its initial state. + * Removes verb array, point array, and weights, and sets FillType to Winding. + * Internal storage associated with Path is released + */ + reset(): void; + + /** + * Sets Path to its initial state. + * Removes verb array, point array, and weights, and sets FillType to Winding. + * Internal storage associated with Path is *not* released. + * Use rewind() instead of reset() if Path storage will be reused and performance + * is critical. + */ + rewind(): void; + + /** + * Relative version of lineTo. + * @param x + * @param y + */ + rLineTo(x: number, y: number): Path; + + /** + * Relative version of moveTo. + * @param x + * @param y + */ + rMoveTo(x: number, y: number): Path; + + /** + * Relative version of quadTo. + * @param x1 + * @param y1 + * @param x2 + * @param y2 + */ + rQuadTo(x1: number, y1: number, x2: number, y2: number): Path; + + /** + * Sets FillType, the rule used to fill Path. + * @param fill + */ + setFillType(fill: FillType): void; + + /** + * Specifies whether Path is volatile; whether it will be altered or discarded + * by the caller after it is drawn. Path by default have volatile set false. + * + * Mark animating or temporary paths as volatile to improve performance. + * Mark unchanging Path non-volatile to improve repeated rendering. + * @param volatile + */ + setIsVolatile(volatile: boolean): void; + + /** + * Set this path to a set of non-overlapping contours that describe the + * same area as the original path. + * The curve order is reduced where possible so that cubics may + * be turned into quadratics, and quadratics maybe turned into lines. + * + * Returns true if operation was able to produce a result. + */ + simplify(): boolean; + + /** + * Turns this path into the filled equivalent of the stroked path. Returns null if the operation + * fails (e.g. the path is a hairline). + * @param opts - describe how stroked path should look. + */ + stroke(opts?: StrokeOpts): Path | null; + + /** + * Serializes the contents of this path as a series of commands. + * The first item will be a verb, followed by any number of arguments needed. Then it will + * be followed by another verb, more arguments and so on. + */ + toCmds(): Float32Array; + + /** + * Returns this path as an SVG string. + */ + toSVGString(): string; + + /** + * Takes a 3x3 matrix as either an array or as 9 individual params. + * @param args + */ + transform(...args: any[]): Path; + + /** + * Take start and stop "t" values (values between 0...1), and modify this path such that + * it is a subset of the original path. + * The trim values apply to the entire path, so if it contains several contours, all of them + * are including in the calculation. + * Null is returned if either input value is NaN. + * @param startT - a value in the range [0.0, 1.0]. 0.0 is the beginning of the path. + * @param stopT - a value in the range [0.0, 1.0]. 1.0 is the end of the path. + * @param isComplement + */ + trim(startT: number, stopT: number, isComplement: boolean): Path | null; +} + +/** + * See SkPathEffect.h for more on this class. The objects are opaque. + */ +export type PathEffect = EmbindObject<"PathEffect">; + +/** + * See SkPicture.h for more information on this class. + * + * Of note, SkPicture is *not* what is colloquially thought of as a "picture" (what we + * call a bitmap). An SkPicture is a series of draw commands. + */ +export interface SkPicture extends EmbindObject<"SkPicture"> { + /** + * Returns a new shader that will draw with this picture. + * + * @param tmx The tiling mode to use when sampling in the x-direction. + * @param tmy The tiling mode to use when sampling in the y-direction. + * @param mode How to filter the tiles + * @param localMatrix Optional matrix used when sampling + * @param tileRect The tile rectangle in picture coordinates: this represents the subset + * (or superset) of the picture used when building a tile. It is not + * affected by localMatrix and does not imply scaling (only translation + * and cropping). If null, the tile rect is considered equal to the picture + * bounds. + */ + makeShader( + tmx: TileMode, + tmy: TileMode, + mode: FilterMode, + localMatrix?: InputMatrix, + tileRect?: InputRect + ): Shader; + + /** + * Return the bounding area for the Picture. + * @param outputArray - if provided, the bounding box will be copied into this array instead of + * allocating a new one. + */ + cullRect(outputArray?: Rect): Rect; + + /** + * Returns the approximate byte size. Does not include large objects. + */ + approximateBytesUsed(): number; + + /** + * Returns the serialized format of this SkPicture. The format may change at anytime and + * no promises are made for backwards or forward compatibility. + */ + serialize(): Uint8Array | null; +} + +export interface PictureRecorder extends EmbindObject<"PictureRecorder"> { + /** + * Returns a canvas on which to draw. When done drawing, call finishRecordingAsPicture() + * + * @param bounds - a rect to cull the results. + * @param computeBounds - Optional boolean (default false) which tells the + * recorder to compute a more accurate bounds for the + * cullRect of the picture. + */ + beginRecording(bounds: InputRect, computeBounds?: boolean): Canvas; + + /** + * Returns the captured draw commands as a picture and invalidates the canvas returned earlier. + */ + finishRecordingAsPicture(): SkPicture; +} + +/** + * See SkRuntimeEffect.h for more details. + */ +export interface RuntimeEffect extends EmbindObject<"RuntimeEffect"> { + /** + * Returns a shader executed using the given uniform data. + * @param uniforms + */ + makeBlender(uniforms: Float32Array | number[] | MallocObj): Blender; + + /** + * Returns a shader executed using the given uniform data. + * @param uniforms + * @param localMatrix + */ + makeShader( + uniforms: Float32Array | number[] | MallocObj, + localMatrix?: InputMatrix + ): Shader; + + /** + * Returns a shader executed using the given uniform data and the children as inputs. + * @param uniforms + * @param children + * @param localMatrix + */ + makeShaderWithChildren( + uniforms: Float32Array | number[] | MallocObj, + children?: Shader[], + localMatrix?: InputMatrix + ): Shader; + + /** + * Returns the nth uniform from the effect. + * @param index + */ + getUniform(index: number): SkSLUniform; + + /** + * Returns the number of uniforms on the effect. + */ + getUniformCount(): number; + + /** + * Returns the total number of floats across all uniforms on the effect. This is the length + * of the uniforms array expected by makeShader. For example, an effect with a single float3 + * uniform, would return 1 from `getUniformCount()`, but 3 from `getUniformFloatCount()`. + */ + getUniformFloatCount(): number; + + /** + * Returns the name of the nth effect uniform. + * @param index + */ + getUniformName(index: number): string; +} + +/** + * See SkShader.h for more on this class. The objects are opaque. + */ +export type Shader = EmbindObject<"Shader">; + +export interface Surface extends EmbindObject<"Surface"> { + /** + * A convenient way to draw exactly once on the canvas associated with this surface. + * This requires an environment where a global function called requestAnimationFrame is + * available (e.g. on the web, not on Node). Users do not need to flush the surface, + * or delete/dispose of it as that is taken care of automatically with this wrapper. + * + * Node users should call getCanvas() and work with that canvas directly. + */ + drawOnce(drawFrame: (_: Canvas) => void): void; + + /** + * Clean up the surface and any extra memory. + * [Deprecated]: In the future, calls to delete() will be sufficient to clean up the memory. + */ + dispose(): void; + + /** + * Make sure any queued draws are sent to the screen or the GPU. + */ + flush(): void; + + /** + * Return a canvas that is backed by this surface. Any draws to the canvas will (eventually) + * show up on the surface. The returned canvas is owned by the surface and does NOT need to + * be cleaned up by the client. + */ + getCanvas(): Canvas; + + /** + * Returns the height of this surface in pixels. + */ + height(): number; + + /** + * Returns the ImageInfo associated with this surface. + */ + imageInfo(): ImageInfo; + + /** + * Creates an Image from the provided texture and info. The Image will own the texture; + * when the image is deleted, the texture will be cleaned up. + * @param tex + * @param info - describes the content of the texture. + */ + makeImageFromTexture(tex: WebGLTexture, info: ImageInfo): Image | null; + + /** + * Returns a texture-backed image based on the content in src. It uses RGBA_8888, unpremul + * and SRGB - for more control, use makeImageFromTexture. + * + * The underlying texture for this image will be created immediately from src, so + * it can be disposed of after this call. This image will *only* be usable for this + * surface (because WebGL textures are not transferable to other WebGL contexts). + * For an image that can be used across multiple surfaces, at the cost of being lazily + * loaded, see MakeLazyImageFromTextureSource. + * + * Not available for software-backed surfaces. + * @param src + * @param info - If provided, will be used to determine the width/height/format of the + * source image. If not, sensible defaults will be used. + * @param srcIsPremul - set to true if the src data has premultiplied alpha. Otherwise, it will + * be assumed to be Unpremultiplied. Note: if this is true and info specifies + * Unpremul, Skia will not convert the src pixels first. + */ + makeImageFromTextureSource( + src: TextureSource, + info?: ImageInfo | PartialImageInfo, + srcIsPremul?: boolean + ): Image | null; + + /** + * Returns current contents of the surface as an Image. This image will be optimized to be + * drawn to another surface of the same type. For example, if this surface is backed by the + * GPU, the returned Image will be backed by a GPU texture. + */ + makeImageSnapshot(bounds?: InputIRect): Image; + + /** + * Returns a compatible Surface, haring the same raster or GPU properties of the original. + * The pixels are not shared. + * @param info - width, height, etc of the Surface. + */ + makeSurface(info: ImageInfo): Surface; + + /** + * Returns if this Surface is a GPU-backed surface or not. + */ + reportBackendTypeIsGPU(): boolean; + + /** + * A convenient way to draw multiple frames on the canvas associated with this surface. + * This requires an environment where a global function called requestAnimationFrame is + * available (e.g. on the web, not on Node). Users do not need to flush the surface, + * as that is taken care of automatically with this wrapper. + * + * Users should probably call surface.requestAnimationFrame in the callback function to + * draw multiple frames, e.g. of an animation. + * + * Node users should call getCanvas() and work with that canvas directly. + * + * Returns the animation id. + */ + requestAnimationFrame(drawFrame: (_: Canvas) => void): number; + + /** + * If this surface is GPU-backed, return the sample count of the surface. + */ + sampleCnt(): number; + + /** + * Updates the underlying GPU texture of the image to be the contents of the provided + * TextureSource. Has no effect on CPU backend or if img was not created with either + * makeImageFromTextureSource or makeImageFromTexture. + * If the provided TextureSource is of different dimensions than the Image, the contents + * will be deformed (e.g. squished). The ColorType, AlphaType, and ColorSpace of src should + * match the original settings used to create the Image or it may draw strange. + * + * @param img - A texture-backed Image. + * @param src - A valid texture source of any dimensions. + * @param srcIsPremul - set to true if the src data has premultiplied alpha. Otherwise, it will + * be assumed to be Unpremultiplied. Note: if this is true and the image was + * created with Unpremul, Skia will not convert. + */ + updateTextureFromSource( + img: Image, + src: TextureSource, + srcIsPremul?: boolean + ): void; + + /** + * Returns the width of this surface in pixels. + */ + width(): number; +} + +/** + * See SkTextBlob.h for more on this class. The objects are opaque. + */ +export type TextBlob = EmbindObject<"TextBlob">; + +/** + * See SkTypeface.h for more on this class. The objects are opaque. + */ +export interface Typeface extends EmbindObject<"Typeface"> { + /** + * Retrieves the glyph ids for each code point in the provided string. Note that glyph IDs + * are typeface-dependent; different faces may have different ids for the same code point. + * @param str + * @param numCodePoints - the number of code points in the string. Defaults to str.length. + * @param output - if provided, the results will be copied into this array. + */ + getGlyphIDs( + str: string, + numCodePoints?: number, + output?: GlyphIDArray + ): GlyphIDArray; + + /** + * Return the typeface family name. + */ + getFamilyName(): string; +} + +/** + * See SkVertices.h for more on this class. + */ +export interface Vertices extends EmbindObject<"Vertices"> { + /** + * Return the bounding area for the vertices. + * @param outputArray - if provided, the bounding box will be copied into this array instead of + * allocating a new one. + */ + bounds(outputArray?: Rect): Rect; + + /** + * Return a unique ID for this vertices object. + */ + uniqueID(): number; +} + +export interface SkottieAnimation extends EmbindObject<"SkottieAnimation"> { + /** + * Returns the animation duration in seconds. + */ + duration(): number; + /** + * Returns the animation frame rate (frames / second). + */ + fps(): number; + + /** + * Draws current animation frame. Must call seek or seekFrame first. + * @param canvas + * @param dstRect + */ + render(canvas: Canvas, dstRect?: InputRect): void; + + /** + * [deprecated] - use seekFrame + * @param t - value from [0.0, 1.0]; 0 is first frame, 1 is final frame. + * @param damageRect - will copy damage frame into this if provided. + */ + seek(t: number, damageRect?: Rect): Rect; + + /** + * Update the animation state to match |t|, specified as a frame index + * i.e. relative to duration() * fps(). + * + * Returns the rectangle that was affected by this animation. + * + * @param frame - Fractional values are allowed and meaningful - e.g. + * 0.0 -> first frame + * 1.0 -> second frame + * 0.5 -> halfway between first and second frame + * @param damageRect - will copy damage frame into this if provided. + */ + seekFrame(frame: number, damageRect?: Rect): Rect; + + /** + * Return the size of this animation. + * @param outputSize - If provided, the size will be copied into here as width, height. + */ + size(outputSize?: Point): Point; + version(): string; +} + +/** + * Options used for Path.stroke(). If an option is omitted, a sensible default will be used. + */ +export interface StrokeOpts { + /** The width of the stroked lines. */ + width?: number; + miter_limit?: number; + /** + * if > 1, increase precision, else if (0 < resScale < 1) reduce precision to + * favor speed and size + */ + precision?: number; + join?: StrokeJoin; + cap?: StrokeCap; +} + +export interface StrutStyle { + strutEnabled?: boolean; + fontFamilies?: string[]; + fontStyle?: FontStyle; + fontSize?: number; + heightMultiplier?: number; + halfLeading?: boolean; + leading?: number; + forceStrutHeight?: boolean; +} + +export interface TextFontFeatures { + name: string; + value: number; +} + +export interface TextFontVariations { + axis: string; + value: number; +} + +export interface TextShadow { + color?: InputColor; + /** + * 2d array for x and y offset. Defaults to [0, 0] + */ + offset?: number[]; + blurRadius?: number; +} + +export interface TextStyle { + backgroundColor?: InputColor; + color?: InputColor; + decoration?: number; + decorationColor?: InputColor; + decorationThickness?: number; + decorationStyle?: DecorationStyle; + fontFamilies?: string[]; + fontFeatures?: TextFontFeatures[]; + fontSize?: number; + fontStyle?: FontStyle; + fontVariations?: TextFontVariations[]; + foregroundColor?: InputColor; + heightMultiplier?: number; + halfLeading?: boolean; + letterSpacing?: number; + locale?: string; + shadows?: TextShadow[]; + textBaseline?: TextBaseline; + wordSpacing?: number; +} + +export interface TonalColorsInput { + ambient: InputColor; + spot: InputColor; +} + +export interface TonalColorsOutput { + ambient: Color; + spot: Color; +} + +export interface TypefaceFontProvider extends FontMgr { + /** + * Registers a given typeface with the given family name (ignoring whatever name the + * typface has for itself). + * @param bytes - the raw bytes for a typeface. + * @param family + */ + registerFont(bytes: ArrayBuffer | Uint8Array, family: string): void; +} + +/** + * See FontCollection.h in SkParagraph for more details + */ +export interface FontCollection extends EmbindObject<"FontCollection"> { + /** + * Enable fallback to dynamically discovered fonts for characters that are not handled + * by the text style's fonts. + */ + enableFontFallback(): void; + + /** + * Set the default provider used to locate fonts. + */ + setDefaultFontManager(fontManager: TypefaceFontProvider | null): void; +} + +export interface URange { + start: number; + end: number; +} + +/** + * Options for configuring a WebGL context. If an option is omitted, a sensible default will + * be used. These are defined by the WebGL standards. + */ +export interface WebGLOptions { + alpha?: number; + antialias?: number; + depth?: number; + enableExtensionsByDefault?: number; + explicitSwapControl?: number; + failIfMajorPerformanceCaveat?: number; + majorVersion?: number; + minorVersion?: number; + preferLowPowerToHighPerformance?: number; + premultipliedAlpha?: number; + preserveDrawingBuffer?: number; + renderViaOffscreenBackBuffer?: number; + stencil?: number; +} + +/** + * Options for configuring a canvas WebGPU context. If an option is omitted, a default specified by + * the WebGPU standard will be used. + */ +export interface WebGPUCanvasOptions { + format?: GPUTextureFormat; + alphaMode?: GPUCanvasAlphaMode; +} + +export interface DefaultConstructor { + new (): T; +} + +export interface ColorMatrixHelpers { + /** + * Returns a new ColorMatrix that is the result of multiplying outer*inner + * @param outer + * @param inner + */ + concat(outer: ColorMatrix, inner: ColorMatrix): ColorMatrix; + + /** + * Returns an identity ColorMatrix. + */ + identity(): ColorMatrix; + + /** + * Sets the 4 "special" params that will translate the colors after they are multiplied + * by the 4x4 matrix. + * @param m + * @param dr - delta red + * @param dg - delta green + * @param db - delta blue + * @param da - delta alpha + */ + postTranslate( + m: ColorMatrix, + dr: number, + dg: number, + db: number, + da: number + ): ColorMatrix; + + /** + * Returns a new ColorMatrix that is rotated around a given axis. + * @param axis - 0 for red, 1 for green, 2 for blue + * @param sine - sin(angle) + * @param cosine - cos(angle) + */ + rotated(axis: number, sine: number, cosine: number): ColorMatrix; + + /** + * Returns a new ColorMatrix that scales the colors as specified. + * @param redScale + * @param greenScale + * @param blueScale + * @param alphaScale + */ + scaled( + redScale: number, + greenScale: number, + blueScale: number, + alphaScale: number + ): ColorMatrix; +} + +/** + * A constructor for making an ImageData that is compatible with the Canvas2D emulation code. + */ +export interface ImageDataConstructor { + new (width: number, height: number): EmulatedImageData; + new ( + pixels: Uint8ClampedArray, + width: number, + height: number + ): EmulatedImageData; +} + +/** + * TODO(kjlubick) Make this API return Float32Arrays + */ +export interface Matrix3x3Helpers { + /** + * Returns a new identity 3x3 matrix. + */ + identity(): number[]; + + /** + * Returns the inverse of the given 3x3 matrix or null if it is not invertible. + * @param m + */ + invert(m: Matrix3x3 | number[]): number[] | null; + + /** + * Maps the given 2d points according to the given 3x3 matrix. + * @param m + * @param points - the flattened points to map; the results are computed in place on this array. + */ + mapPoints(m: Matrix3x3 | number[], points: number[]): number[]; + + /** + * Multiplies the provided 3x3 matrices together from left to right. + * @param matrices + */ + multiply(...matrices: Array): number[]; + + /** + * Returns a new 3x3 matrix representing a rotation by n radians. + * @param radians + * @param px - the X value to rotate around, defaults to 0. + * @param py - the Y value to rotate around, defaults to 0. + */ + rotated(radians: AngleInRadians, px?: number, py?: number): number[]; + + /** + * Returns a new 3x3 matrix representing a scale in the x and y directions. + * @param sx - the scale in the X direction. + * @param sy - the scale in the Y direction. + * @param px - the X value to scale from, defaults to 0. + * @param py - the Y value to scale from, defaults to 0. + */ + scaled(sx: number, sy: number, px?: number, py?: number): number[]; + + /** + * Returns a new 3x3 matrix representing a scale in the x and y directions. + * @param kx - the kurtosis in the X direction. + * @param ky - the kurtosis in the Y direction. + * @param px - the X value to skew from, defaults to 0. + * @param py - the Y value to skew from, defaults to 0. + */ + skewed(kx: number, ky: number, px?: number, py?: number): number[]; + + /** + * Returns a new 3x3 matrix representing a translation in the x and y directions. + * @param dx + * @param dy + */ + translated(dx: number, dy: number): number[]; +} + +/** + * See SkM44.h for more details. + */ +export interface Matrix4x4Helpers { + /** + * Returns a new identity 4x4 matrix. + */ + identity(): number[]; + + /** + * Returns the inverse of the given 4x4 matrix or null if it is not invertible. + * @param matrix + */ + invert(matrix: Matrix4x4 | number[]): number[] | null; + + /** + * Return a new 4x4 matrix representing a camera at eyeVec, pointed at centerVec. + * @param eyeVec + * @param centerVec + * @param upVec + */ + lookat(eyeVec: Vector3, centerVec: Vector3, upVec: Vector3): number[]; + + /** + * Multiplies the provided 4x4 matrices together from left to right. + * @param matrices + */ + multiply(...matrices: Array): number[]; + + /** + * Returns the inverse of the given 4x4 matrix or throws if it is not invertible. + * @param matrix + */ + mustInvert(matrix: Matrix4x4 | number[]): number[]; + + /** + * Returns a new 4x4 matrix representing a perspective. + * @param near + * @param far + * @param radians + */ + perspective(near: number, far: number, radians: AngleInRadians): number[]; + + /** + * Returns the value at the specified row and column of the given 4x4 matrix. + * @param matrix + * @param row + * @param col + */ + rc(matrix: Matrix4x4 | number[], row: number, col: number): number; + + /** + * Returns a new 4x4 matrix representing a rotation around the provided vector. + * @param axis + * @param radians + */ + rotated(axis: Vector3, radians: AngleInRadians): number[]; + + /** + * Returns a new 4x4 matrix representing a rotation around the provided vector. + * Rotation is provided redundantly as both sin and cos values. + * This rotate can be used when you already have the cosAngle and sinAngle values + * so you don't have to atan(cos/sin) to call roatated() which expects an angle in radians. + * This does no checking! Behavior for invalid sin or cos values or non-normalized axis vectors + * is incorrect. Prefer rotated(). + * @param axis + * @param sinAngle + * @param cosAngle + */ + rotatedUnitSinCos( + axis: Vector3, + sinAngle: number, + cosAngle: number + ): number[]; + + /** + * Returns a new 4x4 matrix representing a scale by the provided vector. + * @param vec + */ + scaled(vec: Vector3): number[]; + + /** + * Returns a new 4x4 matrix that sets up a 3D perspective view from a given camera. + * @param area - describes the viewport. (0, 0, canvas_width, canvas_height) suggested. + * @param zScale - describes the scale of the z axis. min(width, height)/2 suggested + * @param cam + */ + setupCamera(area: InputRect, zScale: number, cam: Camera): number[]; + + /** + * Returns a new 4x4 matrix representing a translation by the provided vector. + * @param vec + */ + translated(vec: Vector3): number[]; + + /** + * Returns a new 4x4 matrix that is the transpose of this 4x4 matrix. + * @param matrix + */ + transpose(matrix: Matrix4x4 | number[]): number[]; +} + +/** + * For more information, see SkBlender.h. + */ +export interface BlenderFactory { + /** + * Create a blender that implements the specified BlendMode. + * @param mode + */ + Mode(mode: BlendMode): Blender; +} + +export interface ParagraphBuilderFactory { + /** + * Creates a ParagraphBuilder using the fonts available from the given font manager. + * @param style + * @param fontManager + */ + Make(style: ParagraphStyle, fontManager: FontMgr): ParagraphBuilder; + + /** + * Creates a ParagraphBuilder using the fonts available from the given font provider. + * @param style + * @param fontSrc + */ + MakeFromFontProvider( + style: ParagraphStyle, + fontSrc: TypefaceFontProvider + ): ParagraphBuilder; + + /** + * Creates a ParagraphBuilder using the given font collection. + * @param style + * @param fontCollection + */ + MakeFromFontCollection( + style: ParagraphStyle, + fontCollection: FontCollection + ): ParagraphBuilder; + + /** + * Return a shaped array of lines + */ + ShapeText(text: string, runs: FontBlock[], width?: number): ShapedLine[]; + + /** + * Whether the paragraph builder requires ICU data to be provided by the + * client. + */ + RequiresClientICU(): boolean; +} + +export interface ParagraphStyleConstructor { + /** + * Fills out all optional fields with defaults. The emscripten bindings complain if there + * is a field undefined and it was expecting a float (for example). + * @param ps + */ + new (ps: ParagraphStyle): ParagraphStyle; +} + +/** + * See SkColorFilter.h for more. + */ +export interface ColorFilterFactory { + /** + * Makes a color filter with the given color, blend mode, and colorSpace. + * @param color + * @param mode + * @param colorSpace - If omitted, will use SRGB + */ + MakeBlend( + color: InputColor, + mode: BlendMode, + colorSpace?: ColorSpace + ): ColorFilter; + + /** + * Makes a color filter composing two color filters. + * @param outer + * @param inner + */ + MakeCompose(outer: ColorFilter, inner: ColorFilter): ColorFilter; + + /** + * Makes a color filter that is linearly interpolated between two other color filters. + * @param t - a float in the range of 0.0 to 1.0. + * @param dst + * @param src + */ + MakeLerp(t: number, dst: ColorFilter, src: ColorFilter): ColorFilter; + + /** + * Makes a color filter that converts between linear colors and sRGB colors. + */ + MakeLinearToSRGBGamma(): ColorFilter; + + /** + * Creates a color filter using the provided color matrix. + * @param cMatrix + */ + MakeMatrix(cMatrix: InputColorMatrix): ColorFilter; + + /** + * Makes a color filter that converts between sRGB colors and linear colors. + */ + MakeSRGBToLinearGamma(): ColorFilter; + + /** + * Makes a color filter that multiplies the luma of its input into the alpha channel, + * and sets the red, green, and blue channels to zero. + */ + MakeLuma(): ColorFilter; +} + +export interface ContourMeasureIterConstructor { + /** + * Creates an ContourMeasureIter with the given path. + * @param path + * @param forceClosed - if path should be forced close before measuring it. + * @param resScale - controls the precision of the measure. values > 1 increase the + * precision (and possibly slow down the computation). + */ + new (path: Path, forceClosed: boolean, resScale: number): ContourMeasureIter; +} + +/** + * See SkFont.h for more. + */ +export interface FontConstructor extends DefaultConstructor { + /** + * Constructs Font with default values with Typeface. + * @param face + * @param size - font size in points. If not specified, uses a default value. + */ + new (face: Typeface | null, size?: number): Font; + + /** + * Constructs Font with default values with Typeface and size in points, + * horizontal scale, and horizontal skew. Horizontal scale emulates condensed + * and expanded fonts. Horizontal skew emulates oblique fonts. + * @param face + * @param size + * @param scaleX + * @param skewX + */ + new ( + face: Typeface | null, + size: number, + scaleX: number, + skewX: number + ): Font; +} + +export interface FontMgrFactory { + /** + * Create an FontMgr with the created font data. Returns null if buffers was empty. + * @param buffers + */ + FromData(...buffers: ArrayBuffer[]): FontMgr | null; +} + +/** + * See //include/effects/SkImageFilters.h for more. + */ +export interface ImageFilterFactory { + /** + * Create a filter that takes a BlendMode and uses it to composite the two filters together. + * + * At least one of background and foreground should be non-null in nearly all circumstances. + * + * @param blend The blend mode that defines the compositing operation + * @param background The Dst pixels used in blending; if null, use the dynamic source image + * (e.g. a saved layer). + * @param foreground The Src pixels used in blending; if null, use the dynamic source image. + */ + MakeBlend( + blend: BlendMode, + background: ImageFilter | null, + foreground: ImageFilter | null + ): ImageFilter; + + /** + * Create a filter that blurs its input by the separate X and Y sigmas. The provided tile mode + * is used when the blur kernel goes outside the input image. + * + * @param sigmaX - The Gaussian sigma value for blurring along the X axis. + * @param sigmaY - The Gaussian sigma value for blurring along the Y axis. + * @param mode + * @param input - if null, it will use the dynamic source image (e.g. a saved layer) + */ + MakeBlur( + sigmaX: number, + sigmaY: number, + mode: TileMode, + input: ImageFilter | null + ): ImageFilter; + + /** + * Create a filter that applies the color filter to the input filter results. + * @param cf + * @param input - if null, it will use the dynamic source image (e.g. a saved layer) + */ + MakeColorFilter(cf: ColorFilter, input: ImageFilter | null): ImageFilter; + + /** + * Create a filter that composes 'inner' with 'outer', such that the results of 'inner' are + * treated as the source bitmap passed to 'outer'. + * If either param is null, the other param will be returned. + * @param outer + * @param inner - if null, it will use the dynamic source image (e.g. a saved layer) + */ + MakeCompose( + outer: ImageFilter | null, + inner: ImageFilter | null + ): ImageFilter; + + /** + * Create a filter that dilates each input pixel's channel values to the max value within the + * given radii along the x and y axes. + * @param radiusX The distance to dilate along the x axis to either side of each pixel. + * @param radiusY The distance to dilate along the y axis to either side of each pixel. + * @param input if null, it will use the dynamic source image (e.g. a saved layer). + */ + MakeDilate( + radiusX: number, + radiusY: number, + input: ImageFilter | null + ): ImageFilter; + + /** + * Create a filter that moves each pixel in its color input based on an (x,y) vector encoded + * in its displacement input filter. Two color components of the displacement image are + * mapped into a vector as scale * (color[xChannel], color[yChannel]), where the channel + * selectors are one of R, G, B, or A. + * The mapping takes the 0-255 RGBA values of the image and scales them to be [-0.5 to 0.5], + * in a similar fashion to https://developer.mozilla.org/en-US/docs/Web/SVG/Element/feDisplacementMap + * + * At least one of displacement and color should be non-null in nearly all circumstances. + * + * @param xChannel RGBA channel that encodes the x displacement per pixel. + * @param yChannel RGBA channel that encodes the y displacement per pixel. + * @param scale Scale applied to displacement extracted from image. + * @param displacement The filter defining the displacement image, or null to use source. + * @param color The filter providing the color pixels to be displaced, or null to use source. + */ + MakeDisplacementMap( + xChannel: ColorChannel, + yChannel: ColorChannel, + scale: number, + displacement: ImageFilter | null, + color: ImageFilter | null + ): ImageFilter; + /** + * Create a filter that draws a drop shadow under the input content. This filter produces an + * image that includes the inputs' content. + * @param dx The X offset of the shadow. + * @param dy The Y offset of the shadow. + * @param sigmaX The blur radius for the shadow, along the X axis. + * @param sigmaY The blur radius for the shadow, along the Y axis. + * @param color The color of the drop shadow. + * @param input The input filter; if null, it will use the dynamic source image. + */ + MakeDropShadow( + dx: number, + dy: number, + sigmaX: number, + sigmaY: number, + color: Color, + input: ImageFilter | null + ): ImageFilter; + + /** + * Just like MakeDropShadow, except the input content is not in the resulting image. + * @param dx The X offset of the shadow. + * @param dy The Y offset of the shadow. + * @param sigmaX The blur radius for the shadow, along the X axis. + * @param sigmaY The blur radius for the shadow, along the Y axis. + * @param color The color of the drop shadow. + * @param input The input filter; if null, it will use the dynamic source image. + */ + MakeDropShadowOnly( + dx: number, + dy: number, + sigmaX: number, + sigmaY: number, + color: Color, + input: ImageFilter | null + ): ImageFilter; + + /** + * Create a filter that erodes each input pixel's channel values to the minimum channel value + * within the given radii along the x and y axes. + * @param radiusX The distance to erode along the x axis to either side of each pixel. + * @param radiusY The distance to erode along the y axis to either side of each pixel. + * @param input if null, it will use the dynamic source image (e.g. a saved layer). + */ + MakeErode( + radiusX: number, + radiusY: number, + input: ImageFilter | null + ): ImageFilter; + + /** + * Create a filter using the given image as a source. Returns null if 'image' is null. + * + * @param img The image that is output by the filter, subset by 'srcRect'. + * @param sampling The sampling to use when drawing the image. + */ + MakeImage( + img: Image, + sampling: FilterOptions | CubicResampler + ): ImageFilter | null; + + /** + * Create a filter that draws the 'srcRect' portion of image into 'dstRect' using the given + * filter quality. Similar to Canvas.drawImageRect. Returns null if 'image' is null. + * + * @param img The image that is output by the filter, subset by 'srcRect'. + * @param sampling The sampling to use when drawing the image. + * @param srcRect The source pixels sampled into 'dstRect'. + * @param dstRect The local rectangle to draw the image into. + */ + MakeImage( + img: Image, + sampling: FilterOptions | CubicResampler, + srcRect: InputRect, + dstRect: InputRect + ): ImageFilter | null; + + /** + * Create a filter that transforms the input image by 'matrix'. This matrix transforms the + * local space, which means it effectively happens prior to any transformation coming from the + * Canvas initiating the filtering. + * @param matr + * @param sampling + * @param input - if null, it will use the dynamic source image (e.g. a saved layer) + */ + MakeMatrixTransform( + matr: InputMatrix, + sampling: FilterOptions | CubicResampler, + input: ImageFilter | null + ): ImageFilter; + + /** + * Create a filter that offsets the input filter by the given vector. + * @param dx The x offset in local space that the image is shifted. + * @param dy The y offset in local space that the image is shifted. + * @param input The input that will be moved, if null, will use the dynamic source image. + */ + MakeOffset(dx: number, dy: number, input: ImageFilter | null): ImageFilter; + + /** + * Transforms a shader into an image filter + * + * @param shader - The Shader to be transformed + */ + MakeShader(shader: Shader): ImageFilter; +} + +/** + * See SkMaskFilter.h for more details. + */ +export interface MaskFilterFactory { + /** + * Create a blur maskfilter + * @param style + * @param sigma - Standard deviation of the Gaussian blur to apply. Must be > 0. + * @param respectCTM - if true the blur's sigma is modified by the CTM. + */ + MakeBlur(style: BlurStyle, sigma: number, respectCTM: boolean): MaskFilter; +} + +/** + * Contains the ways to create a Path. + */ +export interface PathConstructorAndFactory extends DefaultConstructor { + /** + * Returns true if the two paths contain equal verbs and equal weights. + * @param path1 first path to compate + * @param path2 second path to compare + * @return true if Path can be interpolated equivalent + */ + CanInterpolate(path1: Path, path2: Path): boolean; + + /** + * Creates a new path from the given list of path commands. If this fails, null will be + * returned instead. + * @param cmds + */ + MakeFromCmds(cmds: InputCommands): Path | null; + + /** + * Creates a new path by combining the given paths according to op. If this fails, null will + * be returned instead. + * @param one + * @param two + * @param op + */ + MakeFromOp(one: Path, two: Path, op: PathOp): Path | null; + + /** + * Interpolates between Path with point array of equal size. + * Copy verb array and weights to result, and set result path to a weighted + * average of this path array and ending path. + * + * weight is most useful when between zero (ending path) and + * one (this path); will work with values outside of this + * range. + * + * interpolate() returns undefined if path is not + * the same size as ending path. Call isInterpolatable() to check Path + * compatibility prior to calling interpolate(). + * + * @param start path to interpolate from + * @param end path to interpolate with + * @param weight contribution of this path, and + * one minus contribution of ending path + * @return Path replaced by interpolated averages or null if + * not interpolatable + */ + MakeFromPathInterpolation( + start: Path, + end: Path, + weight: number + ): Path | null; + + /** + * Creates a new path from the provided SVG string. If this fails, null will be + * returned instead. + * @param str + */ + MakeFromSVGString(str: string): Path | null; + + /** + * Creates a new path using the provided verbs and associated points and weights. The process + * reads the first verb from verbs and then the appropriate number of points from the + * FlattenedPointArray (e.g. 2 points for moveTo, 4 points for quadTo, etc). If the verb is + * a conic, a weight will be read from the WeightList. + * If the data is malformed (e.g. not enough points), the resulting path will be incomplete. + * @param verbs - the verbs that create this path, in the order of being drawn. + * @param points - represents n points with 2n floats. + * @param weights - used if any of the verbs are conics, can be omitted otherwise. + */ + MakeFromVerbsPointsWeights( + verbs: VerbList, + points: InputFlattenedPointArray, + weights?: WeightList + ): Path; +} + +/** + * See SkPathEffect.h for more details. + */ +export interface PathEffectFactory { + /** + * Returns a PathEffect that can turn sharp corners into rounded corners. + * @param radius - if <=0, returns null + */ + MakeCorner(radius: number): PathEffect | null; + + /** + * Returns a PathEffect that add dashes to the path. + * + * See SkDashPathEffect.h for more details. + * + * @param intervals - even number of entries with even indicies specifying the length of + * the "on" intervals, and the odd indices specifying the length of "off". + * @param phase - offset length into the intervals array. Defaults to 0. + */ + MakeDash(intervals: number[], phase?: number): PathEffect; + + /** + * Returns a PathEffect that breaks path into segments of segLength length, and randomly move + * the endpoints away from the original path by a maximum of deviation. + * @param segLength - length of the subsegments. + * @param dev - limit of the movement of the endpoints. + * @param seedAssist - modifies the randomness. See SkDiscretePathEffect.h for more. + */ + MakeDiscrete(segLength: number, dev: number, seedAssist: number): PathEffect; + + /** + * Returns a PathEffect that will fill the drawing path with a pattern made by applying + * the given matrix to a repeating set of infinitely long lines of the given width. + * For example, the scale of the provided matrix will determine how far apart the lines + * should be drawn its rotation affects the lines' orientation. + * @param width - must be >= 0 + * @param matrix + */ + MakeLine2D(width: number, matrix: InputMatrix): PathEffect | null; + + /** + * Returns a PathEffect which implements dashing by replicating the specified path. + * @param path The path to replicate (dash) + * @param advance The space between instances of path + * @param phase distance (mod advance) along path for its initial position + * @param style how to transform path at each point (based on the current + * position and tangent) + */ + MakePath1D( + path: Path, + advance: number, + phase: number, + style: Path1DEffectStyle + ): PathEffect | null; + + /** + * Returns a PathEffect that will fill the drawing path with a pattern by repeating the + * given path according to the provided matrix. For example, the scale of the matrix + * determines how far apart the path instances should be drawn. + * @param matrix + * @param path + */ + MakePath2D(matrix: InputMatrix, path: Path): PathEffect | null; +} + +/** + * See RuntimeEffect.h for more details. + */ +export interface DebugTrace extends EmbindObject<"DebugTrace"> { + writeTrace(): string; +} + +export interface TracedShader { + shader: Shader; + debugTrace: DebugTrace; +} + +export interface RuntimeEffectFactory { + /** + * Compiles a RuntimeEffect from the given shader code. + * @param sksl - Source code for a shader written in SkSL + * @param callback - will be called with any compilation error. If not provided, errors will + * be printed to console.log(). + */ + Make(sksl: string, callback?: (err: string) => void): RuntimeEffect | null; + + /** + * Compiles a RuntimeEffect from the given blender code. + * @param sksl - Source code for a blender written in SkSL + * @param callback - will be called with any compilation error. If not provided, errors will + * be printed to console.log(). + */ + MakeForBlender( + sksl: string, + callback?: (err: string) => void + ): RuntimeEffect | null; + + /** + * Adds debug tracing to an existing RuntimeEffect. + * @param shader - An already-assembled shader, created with RuntimeEffect.makeShader. + * @param traceCoordX - the X coordinate of the device-space pixel to trace + * @param traceCoordY - the Y coordinate of the device-space pixel to trace + */ + MakeTraced( + shader: Shader, + traceCoordX: number, + traceCoordY: number + ): TracedShader; +} + +/** + * For more information, see SkShaders.h. + */ +export interface ShaderFactory { + /** + * Returns a shader that combines the given shaders with a BlendMode. + * @param mode + * @param one + * @param two + */ + MakeBlend(mode: BlendMode, one: Shader, two: Shader): Shader; + + /** + * Returns a shader with a given color and colorspace. + * @param color + * @param space + */ + MakeColor(color: InputColor, space: ColorSpace): Shader; + + /** + * Returns a shader with Perlin Fractal Noise. + * See SkPerlinNoiseShader.h for more details + * @param baseFreqX - base frequency in the X direction; range [0.0, 1.0] + * @param baseFreqY - base frequency in the Y direction; range [0.0, 1.0] + * @param octaves + * @param seed + * @param tileW - if this and tileH are non-zero, the frequencies will be modified so that the + * noise will be tileable for the given size. + * @param tileH - if this and tileW are non-zero, the frequencies will be modified so that the + * noise will be tileable for the given size. + */ + MakeFractalNoise( + baseFreqX: number, + baseFreqY: number, + octaves: number, + seed: number, + tileW: number, + tileH: number + ): Shader; + + /** + * Returns a shader that generates a linear gradient between the two specified points. + * See SkGradientShader.h for more. + * @param start + * @param end + * @param colors - colors to be distributed between start and end. + * @param pos - May be null. The relative positions of colors. If supplied must be same length + * as colors. + * @param mode + * @param localMatrix + * @param flags - By default gradients will interpolate their colors in unpremul space + * and then premultiply each of the results. By setting this to 1, the + * gradients will premultiply their colors first, and then interpolate + * between them. + * @param colorSpace + */ + MakeLinearGradient( + start: InputPoint, + end: InputPoint, + colors: InputFlexibleColorArray, + pos: number[] | null, + mode: TileMode, + localMatrix?: InputMatrix, + flags?: number, + colorSpace?: ColorSpace + ): Shader; + + /** + * Returns a shader that generates a radial gradient given the center and radius. + * See SkGradientShader.h for more. + * @param center + * @param radius + * @param colors - colors to be distributed between the center and edge. + * @param pos - May be null. The relative positions of colors. If supplied must be same length + * as colors. Range [0.0, 1.0] + * @param mode + * @param localMatrix + * @param flags - 0 to interpolate colors in unpremul, 1 to interpolate colors in premul. + * @param colorSpace + */ + MakeRadialGradient( + center: InputPoint, + radius: number, + colors: InputFlexibleColorArray, + pos: number[] | null, + mode: TileMode, + localMatrix?: InputMatrix, + flags?: number, + colorSpace?: ColorSpace + ): Shader; + + /** + * Returns a shader that generates a sweep gradient given a center. + * See SkGradientShader.h for more. + * @param cx + * @param cy + * @param colors - colors to be distributed around the center, within the provided angles. + * @param pos - May be null. The relative positions of colors. If supplied must be same length + * as colors. Range [0.0, 1.0] + * @param mode + * @param localMatrix + * @param flags - 0 to interpolate colors in unpremul, 1 to interpolate colors in premul. + * @param startAngle - angle corresponding to 0.0. Defaults to 0 degrees. + * @param endAngle - angle corresponding to 1.0. Defaults to 360 degrees. + * @param colorSpace + */ + MakeSweepGradient( + cx: number, + cy: number, + colors: InputFlexibleColorArray, + pos: number[] | null, + mode: TileMode, + localMatrix?: InputMatrix | null, + flags?: number, + startAngle?: AngleInDegrees, + endAngle?: AngleInDegrees, + colorSpace?: ColorSpace + ): Shader; + + /** + * Returns a shader with Perlin Turbulence. + * See SkPerlinNoiseShader.h for more details + * @param baseFreqX - base frequency in the X direction; range [0.0, 1.0] + * @param baseFreqY - base frequency in the Y direction; range [0.0, 1.0] + * @param octaves + * @param seed + * @param tileW - if this and tileH are non-zero, the frequencies will be modified so that the + * noise will be tileable for the given size. + * @param tileH - if this and tileW are non-zero, the frequencies will be modified so that the + * noise will be tileable for the given size. + */ + MakeTurbulence( + baseFreqX: number, + baseFreqY: number, + octaves: number, + seed: number, + tileW: number, + tileH: number + ): Shader; + + /** + * Returns a shader that generates a conical gradient given two circles. + * See SkGradientShader.h for more. + * @param start + * @param startRadius + * @param end + * @param endRadius + * @param colors + * @param pos + * @param mode + * @param localMatrix + * @param flags + * @param colorSpace + */ + MakeTwoPointConicalGradient( + start: InputPoint, + startRadius: number, + end: InputPoint, + endRadius: number, + colors: InputFlexibleColorArray, + pos: number[] | null, + mode: TileMode, + localMatrix?: InputMatrix, + flags?: number, + colorSpace?: ColorSpace + ): Shader; +} + +/** + * See SkTextBlob.h for more details. + */ +export interface TextBlobFactory { + /** + * Return a TextBlob with a single run of text. + * + * It does not perform typeface fallback for characters not found in the Typeface. + * It does not perform kerning or other complex shaping; glyphs are positioned based on their + * default advances. + * @param glyphs - if using Malloc'd array, be sure to use CanvasKit.MallocGlyphIDs(). + * @param font + */ + MakeFromGlyphs(glyphs: InputGlyphIDArray, font: Font): TextBlob; + + /** + * Returns a TextBlob built from a single run of text with rotation, scale, and translations. + * + * It uses the default character-to-glyph mapping from the typeface in the font. + * @param str + * @param rsxforms + * @param font + */ + MakeFromRSXform( + str: string, + rsxforms: InputFlattenedRSXFormArray, + font: Font + ): TextBlob; + + /** + * Returns a TextBlob built from a single run of text with rotation, scale, and translations. + * + * @param glyphs - if using Malloc'd array, be sure to use CanvasKit.MallocGlyphIDs(). + * @param rsxforms + * @param font + */ + MakeFromRSXformGlyphs( + glyphs: InputGlyphIDArray, + rsxforms: InputFlattenedRSXFormArray, + font: Font + ): TextBlob; + + /** + * Return a TextBlob with a single run of text. + * + * It uses the default character-to-glyph mapping from the typeface in the font. + * It does not perform typeface fallback for characters not found in the Typeface. + * It does not perform kerning or other complex shaping; glyphs are positioned based on their + * default advances. + * @param str + * @param font + */ + MakeFromText(str: string, font: Font): TextBlob; + + /** + * Returns a TextBlob that has the glyphs following the contours of the given path. + * + * It is a convenience wrapper around MakeFromRSXform and ContourMeasureIter. + * @param str + * @param path + * @param font + * @param initialOffset - the length in pixels to start along the path. + */ + MakeOnPath( + str: string, + path: Path, + font: Font, + initialOffset?: number + ): TextBlob; +} + +export interface TextStyleConstructor { + /** + * Fills out all optional fields with defaults. The emscripten bindings complain if there + * is a field undefined and it was expecting a float (for example). + * @param ts + */ + new (ts: TextStyle): TextStyle; +} + +export interface SlottableTextPropertyConstructor { + /** + * Fills out all optional fields with defaults. The emscripten bindings complain if there + * is a field undefined and it was expecting a float (for example). + * @param text + */ + new (text: SlottableTextProperty): SlottableTextProperty; +} + +export interface TypefaceFactory { + /** + * By default, CanvasKit has a default monospace typeface compiled in so that text works out + * of the box. This returns that typeface if it is available, null otherwise. + */ + GetDefault(): Typeface | null; + + /** + * Create a typeface using Freetype from the specified bytes and return it. CanvasKit supports + * .ttf, .woff and .woff2 fonts. It returns null if the bytes cannot be decoded. + * @param fontData + */ + MakeTypefaceFromData(fontData: ArrayBuffer): Typeface | null; + // Legacy + MakeFreeTypeFaceFromData(fontData: ArrayBuffer): Typeface | null; +} + +export interface TypefaceFontProviderFactory { + /** + * Return an empty TypefaceFontProvider + */ + Make(): TypefaceFontProvider; +} + +export interface FontCollectionFactory { + /** + * Return an empty FontCollection + */ + Make(): FontCollection; +} + +/** + * Functions for manipulating vectors. It is Loosely based off of SkV3 in SkM44.h but Skia + * also has SkVec2 and Skv4. This combines them and works on vectors of any length. + */ +export interface VectorHelpers { + /** + * Adds 2 vectors together, term by term, returning a new Vector. + * @param a + * @param b + */ + add(a: VectorN, b: VectorN): VectorN; + + /** + * Returns the cross product of the two vectors. Only works for length 3. + * @param a + * @param b + */ + cross(a: Vector3, b: Vector3): Vector3; + + /** + * Returns the length(sub(a, b)) + * @param a + * @param b + */ + dist(a: VectorN, b: VectorN): number; + + /** + * Returns the dot product of the two vectors. + * @param a + * @param b + */ + dot(a: VectorN, b: VectorN): number; + + /** + * Returns the length of this vector, which is always positive. + * @param v + */ + length(v: VectorN): number; + + /** + * Returns the length squared of this vector. + * @param v + */ + lengthSquared(v: VectorN): number; + + /** + * Returns a new vector which is v multiplied by the scalar s. + * @param v + * @param s + */ + mulScalar(v: VectorN, s: number): VectorN; + + /** + * Returns a normalized vector. + * @param v + */ + normalize(v: VectorN): VectorN; + + /** + * Subtracts vector b from vector a (termwise). + * @param a + * @param b + */ + sub(a: VectorN, b: VectorN): VectorN; +} + +/** + * A PosTan is a Float32Array of length 4, representing a position and a tangent vector. In order, + * the values are [px, py, tx, ty]. + */ +export type PosTan = Float32Array; +/** + * An Color is represented by 4 floats, typically with values between 0 and 1.0. In order, + * the floats correspond to red, green, blue, alpha. + */ +export type Color = Float32Array; +export type ColorInt = number; // deprecated, prefer Color +/** + * An ColorMatrix is a 4x4 color matrix that transforms the 4 color channels + * with a 1x4 matrix that post-translates those 4 channels. + * For example, the following is the layout with the scale (S) and post-transform + * (PT) items indicated. + * RS, 0, 0, 0 | RPT + * 0, GS, 0, 0 | GPT + * 0, 0, BS, 0 | BPT + * 0, 0, 0, AS | APT + */ +export type ColorMatrix = Float32Array; +/** + * An IRect is represented by 4 ints. In order, the ints correspond to left, top, + * right, bottom. See Rect.h for more + */ +export type IRect = Int32Array; +/** + * A Point is represented by 2 floats: (x, y). + */ +export type Point = Float32Array; +/** + * A Rect is represented by 4 floats. In order, the floats correspond to left, top, + * right, bottom. See Rect.h for more + */ +export type Rect = Float32Array; + +export interface RectWithDirection { + rect: Rect; + dir: TextDirection; +} + +/** + * An RRect (rectangle with rounded corners) is represented by 12 floats. In order, the floats + * correspond to left, top, right, bottom and then in pairs, the radiusX, radiusY for upper-left, + * upper-right, lower-right, lower-left. See RRect.h for more. + */ +export type RRect = Float32Array; + +export type WebGLContextHandle = number; +export type AngleInDegrees = number; +export type AngleInRadians = number; +export type SaveLayerFlag = number; + +export type TypedArrayConstructor = + | Float32ArrayConstructor + | Int32ArrayConstructor + | Int16ArrayConstructor + | Int8ArrayConstructor + | Uint32ArrayConstructor + | Uint16ArrayConstructor + | Uint8ArrayConstructor; +export type TypedArray = + | Float32Array + | Int32Array + | Int16Array + | Int8Array + | Uint32Array + | Uint16Array + | Uint8Array; + +export type ColorIntArray = MallocObj | Uint32Array | number[]; +/** + * FlattenedPointArray represents n points by 2*n float values. In order, the values should + * be the x, y for each point. + */ +export type FlattenedPointArray = Float32Array; +/** + * FlattenedRectangleArray represents n rectangles by 4*n float values. In order, the values should + * be the top, left, right, bottom point for each rectangle. + */ +export type FlattenedRectangleArray = Float32Array; + +export type GlyphIDArray = Uint16Array; +/** + * A command is a verb and then any arguments needed to fulfill that path verb. + * InputCommands is a flattened structure of one or more of these. + * Examples: + * [CanvasKit.MOVE_VERB, 0, 10, + * CanvasKit.QUAD_VERB, 20, 50, 45, 60, + * CanvasKit.LINE_VERB, 30, 40] + */ +export type InputCommands = MallocObj | Float32Array | number[]; +/** + * VerbList holds verb constants like CanvasKit.MOVE_VERB, CanvasKit.CUBIC_VERB. + */ +export type VerbList = MallocObj | Uint8Array | number[]; +/** + * WeightList holds weights for conics when making paths. + */ +export type WeightList = MallocObj | Float32Array | number[]; + +export type Matrix4x4 = Float32Array; +export type Matrix3x3 = Float32Array; +export type Matrix3x2 = Float32Array; + +/** + * Vector2 represents an x, y coordinate or vector. It has length 2. + */ +export type Vector2 = Point; + +/** + * Vector3 represents an x, y, z coordinate or vector. It has length 3. + */ +export type Vector3 = number[]; + +/** + * VectorN represents a vector of length n. + */ +export type VectorN = number[]; + +/** + * CanvasKit APIs accept normal arrays, typed arrays, or Malloc'd memory as colors. + * Length 4. + */ +export type InputColor = MallocObj | Color | number[]; +/** + * CanvasKit APIs accept normal arrays, typed arrays, or Malloc'd memory as color matrices. + * Length 20. + */ +export type InputColorMatrix = MallocObj | ColorMatrix | number[]; +/** + * CanvasKit APIs accept normal arrays, typed arrays, or Malloc'd memory as glyph IDs. + * Length n for n glyph IDs. + */ +export type InputGlyphIDArray = MallocObj | GlyphIDArray | number[]; +/** + * CanvasKit APIs accept normal arrays, typed arrays, or Malloc'd memory as flattened points. + * Length 2 * n for n points. + */ +export type InputFlattenedPointArray = + | MallocObj + | FlattenedPointArray + | number[]; +/** + * CanvasKit APIs accept normal arrays, typed arrays, or Malloc'd memory as flattened rectangles. + * Length 4 * n for n rectangles. + */ +export type InputFlattenedRectangleArray = + | MallocObj + | FlattenedRectangleArray + | number[]; +/** + * Some APIs accept a flattened array of colors in one of two ways - groups of 4 float values for + * r, g, b, a or just integers that have 8 bits for each these. CanvasKit will detect which one + * it is and act accordingly. Additionally, this can be an array of Float32Arrays of length 4 + * (e.g. Color). This is convenient for things like gradients when matching up colors to stops. + */ +export type InputFlexibleColorArray = + | Float32Array + | Uint32Array + | Float32Array[]; +/** + * CanvasKit APIs accept a Float32Array or a normal array (of length 2) as a Point. + */ +export type InputPoint = Point | number[]; +/** + * CanvasKit APIs accept all of these matrix types. Under the hood, we generally use 4x4 matrices. + */ +export type InputMatrix = + | MallocObj + | Matrix4x4 + | Matrix3x3 + | Matrix3x2 + | DOMMatrix + | number[]; +/** + * CanvasKit APIs accept normal arrays, typed arrays, or Malloc'd memory as rectangles. + * Length 4. + */ +export type InputRect = MallocObj | Rect | number[]; +/** + * CanvasKit APIs accept normal arrays, typed arrays, or Malloc'd memory as (int) rectangles. + * Length 4. + */ +export type InputIRect = MallocObj | IRect | number[]; +/** + * CanvasKit APIs accept normal arrays, typed arrays, or Malloc'd memory as rectangles with + * rounded corners. Length 12. + */ +export type InputRRect = MallocObj | RRect | number[]; +/** + * This represents n RSXforms by 4*n float values. In order, the values should + * be scos, ssin, tx, ty for each RSXForm. See RSXForm.h for more details. + */ +export type InputFlattenedRSXFormArray = MallocObj | Float32Array | number[]; + +/** + * InputVector2 maps to InputPoint, the alias is to not use the word "Point" when not accurate, but + * they are in practice the same, a representation of x and y. + */ +export type InputVector2 = InputPoint; +/** + * CanvasKit APIs accept normal arrays, typed arrays, or Malloc'd memory as a vector of 3 floats. + * For example, this is the x, y, z coordinates. + */ +export type InputVector3 = MallocObj | Vector3 | Float32Array; + +/** + * CanvasKit APIs accept normal arrays, typed arrays, or Malloc'd memory + * for bidi regions. Regions are triples of integers + * [startIdx, stopIdx, bidiLevel] + * where startIdx is inclusive and stopIdx is exclusive. + * Length 3 * n where n is the number of regions. + */ +export type InputBidiRegions = MallocObj | Uint32Array | number[]; + +/** + * CanvasKit APIs accept normal arrays, typed arrays, or Malloc'd memory for + * words, graphemes or line breaks. + */ +export type InputWords = MallocObj | Uint32Array | number[]; +export type InputGraphemes = MallocObj | Uint32Array | number[]; +export type InputLineBreaks = MallocObj | Uint32Array | number[]; + +/** + * These are the types that webGL's texImage2D supports as a way to get data from as a texture. + * Not listed, but also supported are https://developer.mozilla.org/en-US/docs/Web/API/VideoFrame + */ +export type TextureSource = + | TypedArray + | HTMLImageElement + | HTMLVideoElement + | ImageData + | ImageBitmap; + +export type AlphaType = EmbindEnumEntity; +export type BlendMode = EmbindEnumEntity; +export type BlurStyle = EmbindEnumEntity; +export type ClipOp = EmbindEnumEntity; +export type ColorChannel = EmbindEnumEntity; +export type ColorSpace = EmbindObject<"ColorSpace">; +export type ColorType = EmbindEnumEntity; +export type EncodedImageFormat = EmbindEnumEntity; +export type FillType = EmbindEnumEntity; +export type FilterMode = EmbindEnumEntity; +export type FontEdging = EmbindEnumEntity; +export type FontHinting = EmbindEnumEntity; +export type MipmapMode = EmbindEnumEntity; +export type PaintStyle = EmbindEnumEntity; +export type Path1DEffectStyle = EmbindEnumEntity; +export type PathOp = EmbindEnumEntity; +export type PointMode = EmbindEnumEntity; +export type StrokeCap = EmbindEnumEntity; +export type StrokeJoin = EmbindEnumEntity; +export type TileMode = EmbindEnumEntity; +export type VertexMode = EmbindEnumEntity; +export type InputState = EmbindEnumEntity; +export type ModifierKey = EmbindEnumEntity; + +export type Affinity = EmbindEnumEntity; +export type DecorationStyle = EmbindEnumEntity; +export type FontSlant = EmbindEnumEntity; +export type FontWeight = EmbindEnumEntity; +export type FontWidth = EmbindEnumEntity; +export type PlaceholderAlignment = EmbindEnumEntity; +export type RectHeightStyle = EmbindEnumEntity; +export type RectWidthStyle = EmbindEnumEntity; +export type TextAlign = EmbindEnumEntity; +export type TextBaseline = EmbindEnumEntity; +export type TextDirection = EmbindEnumEntity; +export type LineBreakType = EmbindEnumEntity; +export type TextHeightBehavior = EmbindEnumEntity; +export type CodeUnitFlags = EmbindEnumEntity; + +export interface AffinityEnumValues extends EmbindEnum { + Upstream: Affinity; + Downstream: Affinity; +} + +export interface AlphaTypeEnumValues extends EmbindEnum { + Opaque: AlphaType; + Premul: AlphaType; + Unpremul: AlphaType; +} + +export interface BlendModeEnumValues extends EmbindEnum { + Clear: BlendMode; + Src: BlendMode; + Dst: BlendMode; + SrcOver: BlendMode; + DstOver: BlendMode; + SrcIn: BlendMode; + DstIn: BlendMode; + SrcOut: BlendMode; + DstOut: BlendMode; + SrcATop: BlendMode; + DstATop: BlendMode; + Xor: BlendMode; + Plus: BlendMode; + Modulate: BlendMode; + Screen: BlendMode; + Overlay: BlendMode; + Darken: BlendMode; + Lighten: BlendMode; + ColorDodge: BlendMode; + ColorBurn: BlendMode; + HardLight: BlendMode; + SoftLight: BlendMode; + Difference: BlendMode; + Exclusion: BlendMode; + Multiply: BlendMode; + Hue: BlendMode; + Saturation: BlendMode; + Color: BlendMode; + Luminosity: BlendMode; +} + +export interface BlurStyleEnumValues extends EmbindEnum { + Normal: BlurStyle; + Solid: BlurStyle; + Outer: BlurStyle; + Inner: BlurStyle; +} + +export interface ClipOpEnumValues extends EmbindEnum { + Difference: ClipOp; + Intersect: ClipOp; +} + +/** + * The currently supported color spaces. These are all singleton values. + */ +export interface ColorSpaceEnumValues { + // not a typical enum, but effectively like one. + // These are all singleton values - don't call delete on them. + readonly SRGB: ColorSpace; + readonly DISPLAY_P3: ColorSpace; + readonly ADOBE_RGB: ColorSpace; + + /** + * Returns true if the two color spaces are equal. + * @param a + * @param b + */ + Equals(a: ColorSpace, b: ColorSpace): boolean; +} + +export interface ColorChannelEnumValues extends EmbindEnum { + Red: ColorChannel; + Green: ColorChannel; + Blue: ColorChannel; + Alpha: ColorChannel; +} + +export interface ColorTypeEnumValues extends EmbindEnum { + Alpha_8: ColorType; + RGB_565: ColorType; + RGBA_8888: ColorType; + BGRA_8888: ColorType; + RGBA_1010102: ColorType; + RGB_101010x: ColorType; + Gray_8: ColorType; + RGBA_F16: ColorType; + RGBA_F32: ColorType; +} + +export interface DecorationStyleEnumValues extends EmbindEnum { + Solid: DecorationStyle; + Double: DecorationStyle; + Dotted: DecorationStyle; + Dashed: DecorationStyle; + Wavy: DecorationStyle; +} + +export interface FillTypeEnumValues extends EmbindEnum { + Winding: FillType; + EvenOdd: FillType; +} + +export interface FilterModeEnumValues extends EmbindEnum { + Linear: FilterMode; + Nearest: FilterMode; +} + +export interface FontEdgingEnumValues extends EmbindEnum { + Alias: FontEdging; + AntiAlias: FontEdging; + SubpixelAntiAlias: FontEdging; +} + +export interface FontHintingEnumValues extends EmbindEnum { + None: FontHinting; + Slight: FontHinting; + Normal: FontHinting; + Full: FontHinting; +} + +export interface FontSlantEnumValues extends EmbindEnum { + Upright: FontSlant; + Italic: FontSlant; + Oblique: FontSlant; +} + +export interface FontWeightEnumValues extends EmbindEnum { + Invisible: FontWeight; + Thin: FontWeight; + ExtraLight: FontWeight; + Light: FontWeight; + Normal: FontWeight; + Medium: FontWeight; + SemiBold: FontWeight; + Bold: FontWeight; + ExtraBold: FontWeight; + Black: FontWeight; + ExtraBlack: FontWeight; +} + +export interface FontWidthEnumValues extends EmbindEnum { + UltraCondensed: FontWidth; + ExtraCondensed: FontWidth; + Condensed: FontWidth; + SemiCondensed: FontWidth; + Normal: FontWidth; + SemiExpanded: FontWidth; + Expanded: FontWidth; + ExtraExpanded: FontWidth; + UltraExpanded: FontWidth; +} + +/* + * These values can be OR'd together + */ +export interface GlyphRunFlagValues { + IsWhiteSpace: number; +} + +export interface ImageFormatEnumValues extends EmbindEnum { + // TODO(kjlubick) When these are compiled in depending on the availability of the codecs, + // be sure to make these nullable. + PNG: EncodedImageFormat; + JPEG: EncodedImageFormat; + WEBP: EncodedImageFormat; +} + +export interface MipmapModeEnumValues extends EmbindEnum { + None: MipmapMode; + Nearest: MipmapMode; + Linear: MipmapMode; +} + +export interface PaintStyleEnumValues extends EmbindEnum { + Fill: PaintStyle; + Stroke: PaintStyle; +} + +export interface Path1DEffectStyleEnumValues extends EmbindEnum { + // Translate the shape to each position + Translate: Path1DEffectStyle; + // Rotate the shape about its center + Rotate: Path1DEffectStyle; + // Transform each point and turn lines into curves + Morph: Path1DEffectStyle; +} + +export interface PathOpEnumValues extends EmbindEnum { + Difference: PathOp; + Intersect: PathOp; + Union: PathOp; + XOR: PathOp; + ReverseDifference: PathOp; +} + +export interface PlaceholderAlignmentEnumValues extends EmbindEnum { + Baseline: PlaceholderAlignment; + AboveBaseline: PlaceholderAlignment; + BelowBaseline: PlaceholderAlignment; + Top: PlaceholderAlignment; + Bottom: PlaceholderAlignment; + Middle: PlaceholderAlignment; +} + +export interface CodeUnitFlagsEnumValues extends EmbindEnum { + NoCodeUnitFlag: CodeUnitFlags; + Whitespace: CodeUnitFlags; + Space: CodeUnitFlags; + Control: CodeUnitFlags; + Ideographic: CodeUnitFlags; +} + +export interface PointModeEnumValues extends EmbindEnum { + Points: PointMode; + Lines: PointMode; + Polygon: PointMode; +} + +export interface RectHeightStyleEnumValues extends EmbindEnum { + Tight: RectHeightStyle; + Max: RectHeightStyle; + IncludeLineSpacingMiddle: RectHeightStyle; + IncludeLineSpacingTop: RectHeightStyle; + IncludeLineSpacingBottom: RectHeightStyle; + Strut: RectHeightStyle; +} + +export interface RectWidthStyleEnumValues extends EmbindEnum { + Tight: RectWidthStyle; + Max: RectWidthStyle; +} + +export interface StrokeCapEnumValues extends EmbindEnum { + Butt: StrokeCap; + Round: StrokeCap; + Square: StrokeCap; +} + +export interface StrokeJoinEnumValues extends EmbindEnum { + Bevel: StrokeJoin; + Miter: StrokeJoin; + Round: StrokeJoin; +} + +export interface TextAlignEnumValues extends EmbindEnum { + Left: TextAlign; + Right: TextAlign; + Center: TextAlign; + Justify: TextAlign; + Start: TextAlign; + End: TextAlign; +} + +export interface TextBaselineEnumValues extends EmbindEnum { + Alphabetic: TextBaseline; + Ideographic: TextBaseline; +} + +export interface TextDirectionEnumValues extends EmbindEnum { + LTR: TextDirection; + RTL: TextDirection; +} + +export interface LineBreakTypeEnumValues extends EmbindEnum { + SoftLineBreak: LineBreakType; + HardtLineBreak: LineBreakType; +} + +export interface TextHeightBehaviorEnumValues extends EmbindEnum { + All: TextHeightBehavior; + DisableFirstAscent: TextHeightBehavior; + DisableLastDescent: TextHeightBehavior; + DisableAll: TextHeightBehavior; +} + +export interface TileModeEnumValues extends EmbindEnum { + Clamp: TileMode; + Decal: TileMode; + Mirror: TileMode; + Repeat: TileMode; +} + +export interface VertexModeEnumValues extends EmbindEnum { + Triangles: VertexMode; + TrianglesStrip: VertexMode; + TriangleFan: VertexMode; +} + +export interface InputStateEnumValues extends EmbindEnum { + Down: InputState; + Up: InputState; + Move: InputState; + Right: InputState; // fling only + Left: InputState; // fling only +} + +export interface ModifierKeyEnumValues extends EmbindEnum { + None: ModifierKey; + Shift: ModifierKey; + Control: ModifierKey; + Option: ModifierKey; + Command: ModifierKey; + FirstPress: ModifierKey; +} + +export type VerticalAlign = EmbindEnumEntity; + +export interface VerticalTextAlignEnumValues extends EmbindEnum { + Top: VerticalAlign; + TopBaseline: VerticalAlign; + + // Skottie vertical alignment extensions + // Visual alignement modes -- these are using tight visual bounds for the paragraph. + VisualTop: VerticalAlign; // visual top -> text box top + VisualCenter: VerticalAlign; // visual center -> text box center + VisualBottom: VerticalAlign; // visual bottom -> text box bottom +} + +export type ResizePolicy = EmbindEnumEntity; + +export interface ResizePolicyEnumValues extends EmbindEnum { + // Use the specified text size. + None: ResizePolicy; + // Resize the text such that the extent box fits (snuggly) in the text box, + // both horizontally and vertically. + ScaleToFit: ResizePolicy; + // Same kScaleToFit if the text doesn't fit at the specified font size. + // Otherwise, same as kNone. + DownscaleToFit: ResizePolicy; +} diff --git a/.ref/figma/README.md b/.ref/figma/README.md new file mode 100644 index 0000000000..c57c1d5844 --- /dev/null +++ b/.ref/figma/README.md @@ -0,0 +1,4 @@ +# Figma typings reference + +- https://github.com/figma/plugin-typings/blob/master/plugin-api.d.ts +- https://github.com/figma/rest-api-spec/blob/main/dist/api_types.ts diff --git a/packages/grida-canvas-schema/.ref/figma.d.ts b/.ref/figma/figma-plugin-api.d.ts similarity index 100% rename from packages/grida-canvas-schema/.ref/figma.d.ts rename to .ref/figma/figma-plugin-api.d.ts diff --git a/.ref/figma/figma-rest-api.d.ts b/.ref/figma/figma-rest-api.d.ts new file mode 100644 index 0000000000..e8dab55cc7 --- /dev/null +++ b/.ref/figma/figma-rest-api.d.ts @@ -0,0 +1,7351 @@ +export type IsLayerTrait = { + /** + * A string uniquely identifying this node within the document. + */ + id: string + + /** + * The name given to the node by the user in the tool. + */ + name: string + + /** + * The type of the node + */ + type: string + + /** + * Whether or not the node is visible on the canvas. + */ + visible?: boolean + + /** + * If true, layer is locked and cannot be edited + */ + locked?: boolean + + /** + * Whether the layer is fixed while the parent is scrolling + * + * @deprecated + */ + isFixed?: boolean + + /** + * How layer should be treated when the frame is resized + */ + scrollBehavior: 'SCROLLS' | 'FIXED' | 'STICKY_SCROLLS' + + /** + * The rotation of the node, if not 0. + */ + rotation?: number + + /** + * A mapping of a layer's property to component property name of component properties attached to + * this node. The component property name can be used to look up more information on the + * corresponding component's or component set's componentPropertyDefinitions. + */ + componentPropertyReferences?: { [key: string]: string } + + /** + * Data written by plugins that is visible only to the plugin that wrote it. Requires the + * `pluginData` to include the ID of the plugin. + */ + pluginData?: unknown + + /** + * Data written by plugins that is visible to all plugins. Requires the `pluginData` parameter to + * include the string "shared". + */ + sharedPluginData?: unknown + + /** + * A mapping of field to the variables applied to this field. Most fields will only map to a single + * `VariableAlias`. However, for properties like `fills`, `strokes`, `size`, `componentProperties`, + * and `textRangeFills`, it is possible to have multiple variables bound to the field. + */ + boundVariables?: { + size?: { + x?: VariableAlias + + y?: VariableAlias + } + + individualStrokeWeights?: { + top?: VariableAlias + + bottom?: VariableAlias + + left?: VariableAlias + + right?: VariableAlias + } + + characters?: VariableAlias + + itemSpacing?: VariableAlias + + paddingLeft?: VariableAlias + + paddingRight?: VariableAlias + + paddingTop?: VariableAlias + + paddingBottom?: VariableAlias + + visible?: VariableAlias + + topLeftRadius?: VariableAlias + + topRightRadius?: VariableAlias + + bottomLeftRadius?: VariableAlias + + bottomRightRadius?: VariableAlias + + minWidth?: VariableAlias + + maxWidth?: VariableAlias + + minHeight?: VariableAlias + + maxHeight?: VariableAlias + + counterAxisSpacing?: VariableAlias + + opacity?: VariableAlias + + fontFamily?: VariableAlias[] + + fontSize?: VariableAlias[] + + fontStyle?: VariableAlias[] + + fontWeight?: VariableAlias[] + + letterSpacing?: VariableAlias[] + + lineHeight?: VariableAlias[] + + paragraphSpacing?: VariableAlias[] + + paragraphIndent?: VariableAlias[] + + fills?: VariableAlias[] + + strokes?: VariableAlias[] + + componentProperties?: { [key: string]: VariableAlias } + + textRangeFills?: VariableAlias[] + + effects?: VariableAlias[] + + layoutGrids?: VariableAlias[] + + rectangleCornerRadii?: { + RECTANGLE_TOP_LEFT_CORNER_RADIUS?: VariableAlias + + RECTANGLE_TOP_RIGHT_CORNER_RADIUS?: VariableAlias + + RECTANGLE_BOTTOM_LEFT_CORNER_RADIUS?: VariableAlias + + RECTANGLE_BOTTOM_RIGHT_CORNER_RADIUS?: VariableAlias + } + } + + /** + * A mapping of variable collection ID to mode ID representing the explicitly set modes for this + * node. + */ + explicitVariableModes?: { [key: string]: string } +} + +export type HasChildrenTrait = { + /** + * An array of nodes that are direct children of this node + */ + children: SubcanvasNode[] +} + +export type HasLayoutTrait = { + /** + * Bounding box of the node in absolute space coordinates. + */ + absoluteBoundingBox: Rectangle | null + + /** + * The actual bounds of a node accounting for drop shadows, thick strokes, and anything else that + * may fall outside the node's regular bounding box defined in `x`, `y`, `width`, and `height`. The + * `x` and `y` inside this property represent the absolute position of the node on the page. This + * value will be `null` if the node is invisible. + */ + absoluteRenderBounds: Rectangle | null + + /** + * Keep height and width constrained to same ratio. + */ + preserveRatio?: boolean + + /** + * Horizontal and vertical layout constraints for node. + */ + constraints?: LayoutConstraint + + /** + * The top two rows of a matrix that represents the 2D transform of this node relative to its + * parent. The bottom row of the matrix is implicitly always (0, 0, 1). Use to transform coordinates + * in geometry. Only present if `geometry=paths` is passed. + */ + relativeTransform?: Transform + + /** + * Width and height of element. This is different from the width and height of the bounding box in + * that the absolute bounding box represents the element after scaling and rotation. Only present if + * `geometry=paths` is passed. + */ + size?: Vector + + /** + * Determines if the layer should stretch along the parent's counter axis. This property is only + * provided for direct children of auto-layout frames. + * + * - `INHERIT` + * - `STRETCH` + * + * In previous versions of auto layout, determined how the layer is aligned inside an auto-layout + * frame. This property is only provided for direct children of auto-layout frames. + * + * - `MIN` + * - `CENTER` + * - `MAX` + * - `STRETCH` + * + * In horizontal auto-layout frames, "MIN" and "MAX" correspond to "TOP" and "BOTTOM". In vertical + * auto-layout frames, "MIN" and "MAX" correspond to "LEFT" and "RIGHT". + */ + layoutAlign?: 'INHERIT' | 'STRETCH' | 'MIN' | 'CENTER' | 'MAX' + + /** + * This property is applicable only for direct children of auto-layout frames, ignored otherwise. + * Determines whether a layer should stretch along the parent's primary axis. A `0` corresponds to a + * fixed size and `1` corresponds to stretch. + */ + layoutGrow?: 0 | 1 + + /** + * Determines whether a layer's size and position should be determined by auto-layout settings or + * manually adjustable. + */ + layoutPositioning?: 'AUTO' | 'ABSOLUTE' + + /** + * The minimum width of the frame. This property is only applicable for auto-layout frames or direct + * children of auto-layout frames. + */ + minWidth?: number + + /** + * The maximum width of the frame. This property is only applicable for auto-layout frames or direct + * children of auto-layout frames. + */ + maxWidth?: number + + /** + * The minimum height of the frame. This property is only applicable for auto-layout frames or + * direct children of auto-layout frames. + */ + minHeight?: number + + /** + * The maximum height of the frame. This property is only applicable for auto-layout frames or + * direct children of auto-layout frames. + */ + maxHeight?: number + + /** + * The horizontal sizing setting on this auto-layout frame or frame child. + * + * - `FIXED` + * - `HUG`: only valid on auto-layout frames and text nodes + * - `FILL`: only valid on auto-layout frame children + */ + layoutSizingHorizontal?: 'FIXED' | 'HUG' | 'FILL' + + /** + * The vertical sizing setting on this auto-layout frame or frame child. + * + * - `FIXED` + * - `HUG`: only valid on auto-layout frames and text nodes + * - `FILL`: only valid on auto-layout frame children + */ + layoutSizingVertical?: 'FIXED' | 'HUG' | 'FILL' +} + +export type HasFramePropertiesTrait = { + /** + * Whether or not this node clip content outside of its bounds + */ + clipsContent: boolean + + /** + * Background of the node. This is deprecated, as backgrounds for frames are now in the `fills` + * field. + * + * @deprecated + */ + background?: Paint[] + + /** + * Background color of the node. This is deprecated, as frames now support more than a solid color + * as a background. Please use the `fills` field instead. + * + * @deprecated + */ + backgroundColor?: RGBA + + /** + * An array of layout grids attached to this node (see layout grids section for more details). GROUP + * nodes do not have this attribute + */ + layoutGrids?: LayoutGrid[] + + /** + * Whether a node has primary axis scrolling, horizontal or vertical. + */ + overflowDirection?: + | 'HORIZONTAL_SCROLLING' + | 'VERTICAL_SCROLLING' + | 'HORIZONTAL_AND_VERTICAL_SCROLLING' + | 'NONE' + + /** + * Whether this layer uses auto-layout to position its children. + */ + layoutMode?: 'NONE' | 'HORIZONTAL' | 'VERTICAL' + + /** + * Whether the primary axis has a fixed length (determined by the user) or an automatic length + * (determined by the layout engine). This property is only applicable for auto-layout frames. + */ + primaryAxisSizingMode?: 'FIXED' | 'AUTO' + + /** + * Whether the counter axis has a fixed length (determined by the user) or an automatic length + * (determined by the layout engine). This property is only applicable for auto-layout frames. + */ + counterAxisSizingMode?: 'FIXED' | 'AUTO' + + /** + * Determines how the auto-layout frame's children should be aligned in the primary axis direction. + * This property is only applicable for auto-layout frames. + */ + primaryAxisAlignItems?: 'MIN' | 'CENTER' | 'MAX' | 'SPACE_BETWEEN' + + /** + * Determines how the auto-layout frame's children should be aligned in the counter axis direction. + * This property is only applicable for auto-layout frames. + */ + counterAxisAlignItems?: 'MIN' | 'CENTER' | 'MAX' | 'BASELINE' + + /** + * The padding between the left border of the frame and its children. This property is only + * applicable for auto-layout frames. + */ + paddingLeft?: number + + /** + * The padding between the right border of the frame and its children. This property is only + * applicable for auto-layout frames. + */ + paddingRight?: number + + /** + * The padding between the top border of the frame and its children. This property is only + * applicable for auto-layout frames. + */ + paddingTop?: number + + /** + * The padding between the bottom border of the frame and its children. This property is only + * applicable for auto-layout frames. + */ + paddingBottom?: number + + /** + * The distance between children of the frame. Can be negative. This property is only applicable for + * auto-layout frames. + */ + itemSpacing?: number + + /** + * Determines the canvas stacking order of layers in this frame. When true, the first layer will be + * draw on top. This property is only applicable for auto-layout frames. + */ + itemReverseZIndex?: boolean + + /** + * Determines whether strokes are included in layout calculations. When true, auto-layout frames + * behave like css "box-sizing: border-box". This property is only applicable for auto-layout + * frames. + */ + strokesIncludedInLayout?: boolean + + /** + * Whether this auto-layout frame has wrapping enabled. + */ + layoutWrap?: 'NO_WRAP' | 'WRAP' + + /** + * The distance between wrapped tracks of an auto-layout frame. This property is only applicable for + * auto-layout frames with `layoutWrap: "WRAP"` + */ + counterAxisSpacing?: number + + /** + * Determines how the auto-layout frame’s wrapped tracks should be aligned in the counter axis + * direction. This property is only applicable for auto-layout frames with `layoutWrap: "WRAP"`. + */ + counterAxisAlignContent?: 'AUTO' | 'SPACE_BETWEEN' +} + +export type HasBlendModeAndOpacityTrait = { + /** + * How this node blends with nodes behind it in the scene (see blend mode section for more details) + */ + blendMode: BlendMode + + /** + * Opacity of the node + */ + opacity?: number +} + +export type HasExportSettingsTrait = { + /** + * An array of export settings representing images to export from the node. + */ + exportSettings?: ExportSetting[] +} + +export type HasGeometryTrait = MinimalFillsTrait & + MinimalStrokesTrait & { + /** + * Map from ID to PaintOverride for looking up fill overrides. To see which regions are overriden, + * you must use the `geometry=paths` option. Each path returned may have an `overrideID` which maps + * to this table. + */ + fillOverrideTable?: { [key: string]: PaintOverride | null } + + /** + * Only specified if parameter `geometry=paths` is used. An array of paths representing the object + * fill. + */ + fillGeometry?: Path[] + + /** + * Only specified if parameter `geometry=paths` is used. An array of paths representing the object + * stroke. + */ + strokeGeometry?: Path[] + + /** + * A string enum describing the end caps of vector paths. + */ + strokeCap?: + | 'NONE' + | 'ROUND' + | 'SQUARE' + | 'LINE_ARROW' + | 'TRIANGLE_ARROW' + | 'DIAMOND_FILLED' + | 'CIRCLE_FILLED' + | 'TRIANGLE_FILLED' + | 'WASHI_TAPE_1' + | 'WASHI_TAPE_2' + | 'WASHI_TAPE_3' + | 'WASHI_TAPE_4' + | 'WASHI_TAPE_5' + | 'WASHI_TAPE_6' + + /** + * Only valid if `strokeJoin` is "MITER". The corner angle, in degrees, below which `strokeJoin` + * will be set to "BEVEL" to avoid super sharp corners. By default this is 28.96 degrees. + */ + strokeMiterAngle?: number + } + +export type MinimalFillsTrait = { + /** + * An array of fill paints applied to the node. + */ + fills: Paint[] + + /** + * A mapping of a StyleType to style ID (see Style) of styles present on this node. The style ID can + * be used to look up more information about the style in the top-level styles field. + */ + styles?: { [key: string]: string } +} + +export type MinimalStrokesTrait = { + /** + * An array of stroke paints applied to the node. + */ + strokes?: Paint[] + + /** + * The weight of strokes on the node. + */ + strokeWeight?: number + + /** + * Position of stroke relative to vector outline, as a string enum + * + * - `INSIDE`: stroke drawn inside the shape boundary + * - `OUTSIDE`: stroke drawn outside the shape boundary + * - `CENTER`: stroke drawn centered along the shape boundary + */ + strokeAlign?: 'INSIDE' | 'OUTSIDE' | 'CENTER' + + /** + * A string enum with value of "MITER", "BEVEL", or "ROUND", describing how corners in vector paths + * are rendered. + */ + strokeJoin?: 'MITER' | 'BEVEL' | 'ROUND' + + /** + * An array of floating point numbers describing the pattern of dash length and gap lengths that the + * vector stroke will use when drawn. + * + * For example a value of [1, 2] indicates that the stroke will be drawn with a dash of length 1 + * followed by a gap of length 2, repeated. + */ + strokeDashes?: number[] +} + +export type IndividualStrokesTrait = { + /** + * An object including the top, bottom, left, and right stroke weights. Only returned if individual + * stroke weights are used. + */ + individualStrokeWeights?: StrokeWeights +} + +export type CornerTrait = { + /** + * Radius of each corner if a single radius is set for all corners + */ + cornerRadius?: number + + /** + * A value that lets you control how "smooth" the corners are. Ranges from 0 to 1. 0 is the default + * and means that the corner is perfectly circular. A value of 0.6 means the corner matches the iOS + * 7 "squircle" icon shape. Other values produce various other curves. + */ + cornerSmoothing?: number + + /** + * Array of length 4 of the radius of each corner of the frame, starting in the top left and + * proceeding clockwise. + * + * Values are given in the order top-left, top-right, bottom-right, bottom-left. + */ + rectangleCornerRadii?: number[] +} + +export type HasEffectsTrait = { + /** + * An array of effects attached to this node (see effects section for more details) + */ + effects: Effect[] +} + +export type HasMaskTrait = { + /** + * Does this node mask sibling nodes in front of it? + */ + isMask?: boolean + + /** + * If this layer is a mask, this property describes the operation used to mask the layer's siblings. + * The value may be one of the following: + * + * - ALPHA: the mask node's alpha channel will be used to determine the opacity of each pixel in the + * masked result. + * - VECTOR: if the mask node has visible fill paints, every pixel inside the node's fill regions will + * be fully visible in the masked result. If the mask has visible stroke paints, every pixel + * inside the node's stroke regions will be fully visible in the masked result. + * - LUMINANCE: the luminance value of each pixel of the mask node will be used to determine the + * opacity of that pixel in the masked result. + */ + maskType?: 'ALPHA' | 'VECTOR' | 'LUMINANCE' + + /** + * True if maskType is VECTOR. This field is deprecated; use maskType instead. + * + * @deprecated + */ + isMaskOutline?: boolean +} + +export type ComponentPropertiesTrait = { + /** + * A mapping of name to `ComponentPropertyDefinition` for every component property on this + * component. Each property has a type, defaultValue, and other optional values. + */ + componentPropertyDefinitions?: { [key: string]: ComponentPropertyDefinition } +} + +export type TypePropertiesTrait = { + /** + * The raw characters in the text node. + */ + characters: string + + /** + * Style of text including font family and weight. + */ + style: TypeStyle + + /** + * The array corresponds to characters in the text box, where each element references the + * 'styleOverrideTable' to apply specific styles to each character. The array's length can be less + * than or equal to the number of characters due to the removal of trailing zeros. Elements with a + * value of 0 indicate characters that use the default type style. If the array is shorter than the + * total number of characters, the characters beyond the array's length also use the default style. + */ + characterStyleOverrides: number[] + + /** + * Internal property, preserved for backward compatibility. Avoid using this value. + */ + layoutVersion?: number + + /** + * Map from ID to TypeStyle for looking up style overrides. + */ + styleOverrideTable: { [key: string]: TypeStyle } + + /** + * An array with the same number of elements as lines in the text node, where lines are delimited by + * newline or paragraph separator characters. Each element in the array corresponds to the list type + * of a specific line. List types are represented as string enums with one of these possible + * values: + * + * - `NONE`: Not a list item. + * - `ORDERED`: Text is an ordered list (numbered). + * - `UNORDERED`: Text is an unordered list (bulleted). + */ + lineTypes: ('NONE' | 'ORDERED' | 'UNORDERED')[] + + /** + * An array with the same number of elements as lines in the text node, where lines are delimited by + * newline or paragraph separator characters. Each element in the array corresponds to the + * indentation level of a specific line. + */ + lineIndentations: number[] +} + +export type TextPathPropertiesTrait = { + /** + * The raw characters in the text path node. + */ + characters: string + + /** + * Style of text including font family and weight. + */ + style: TextPathTypeStyle + + /** + * The array corresponds to characters in the text box, where each element references the + * 'styleOverrideTable' to apply specific styles to each character. The array's length can be less + * than or equal to the number of characters due to the removal of trailing zeros. Elements with a + * value of 0 indicate characters that use the default type style. If the array is shorter than the + * total number of characters, the characters beyond the array's length also use the default style. + */ + characterStyleOverrides: number[] + + /** + * Internal property, preserved for backward compatibility. Avoid using this value. + */ + layoutVersion?: number + + /** + * Map from ID to TextPathTypeStyle for looking up style overrides. + */ + styleOverrideTable: { [key: string]: TextPathTypeStyle } +} + +export type HasTextSublayerTrait = { + /** + * Text contained within a text box. + */ + characters: string +} + +export type TransitionSourceTrait = { + /** + * Node ID of node to transition to in prototyping + */ + transitionNodeID?: string + + /** + * The duration of the prototyping transition on this node (in milliseconds). This will override the + * default transition duration on the prototype, for this node. + */ + transitionDuration?: number + + /** + * The easing curve used in the prototyping transition on this node. + */ + transitionEasing?: EasingType + + interactions?: Interaction[] +} + +export type DevStatusTrait = { + /** + * Represents whether or not a node has a particular handoff (or dev) status applied to it. + */ + devStatus?: { + type: 'NONE' | 'READY_FOR_DEV' | 'COMPLETED' + + /** + * An optional field where the designer can add more information about the design and what has + * changed. + */ + description?: string + } +} + +export type AnnotationsTrait = object + +export type FrameTraits = IsLayerTrait & + HasBlendModeAndOpacityTrait & + HasChildrenTrait & + HasLayoutTrait & + HasFramePropertiesTrait & + CornerTrait & + HasGeometryTrait & + HasExportSettingsTrait & + HasEffectsTrait & + HasMaskTrait & + TransitionSourceTrait & + IndividualStrokesTrait & + DevStatusTrait & + AnnotationsTrait + +export type DefaultShapeTraits = IsLayerTrait & + HasBlendModeAndOpacityTrait & + HasLayoutTrait & + HasGeometryTrait & + HasExportSettingsTrait & + HasEffectsTrait & + HasMaskTrait & + TransitionSourceTrait + +export type CornerRadiusShapeTraits = DefaultShapeTraits & CornerTrait + +export type RectangularShapeTraits = DefaultShapeTraits & + CornerTrait & + IndividualStrokesTrait & + AnnotationsTrait + +export type Node = + | BooleanOperationNode + | ComponentNode + | ComponentSetNode + | ConnectorNode + | EllipseNode + | EmbedNode + | FrameNode + | GroupNode + | InstanceNode + | LineNode + | LinkUnfurlNode + | RectangleNode + | RegularPolygonNode + | SectionNode + | ShapeWithTextNode + | SliceNode + | StarNode + | StickyNode + | TableNode + | TableCellNode + | TextNode + | TextPathNode + | TransformGroupNode + | VectorNode + | WashiTapeNode + | WidgetNode + | DocumentNode + | CanvasNode + +export type DocumentNode = { + type: 'DOCUMENT' + + children: CanvasNode[] +} & IsLayerTrait + +export type CanvasNode = { + type: 'CANVAS' + + children: SubcanvasNode[] + + /** + * Background color of the canvas. + */ + backgroundColor: RGBA + + /** + * Node ID that corresponds to the start frame for prototypes. This is deprecated with the + * introduction of multiple flows. Please use the `flowStartingPoints` field. + * + * @deprecated + */ + prototypeStartNodeID: string | null + + /** + * An array of flow starting points sorted by its position in the prototype settings panel. + */ + flowStartingPoints: FlowStartingPoint[] + + /** + * The device used to view a prototype. + */ + prototypeDevice: PrototypeDevice + + /** + * The background color of the prototype (currently only supports a single solid color paint). + */ + prototypeBackgrounds?: RGBA[] + + measurements?: Measurement[] +} & IsLayerTrait & + HasExportSettingsTrait + +export type SubcanvasNode = + | BooleanOperationNode + | ComponentNode + | ComponentSetNode + | ConnectorNode + | EllipseNode + | EmbedNode + | FrameNode + | GroupNode + | InstanceNode + | LineNode + | LinkUnfurlNode + | RectangleNode + | RegularPolygonNode + | SectionNode + | ShapeWithTextNode + | SliceNode + | StarNode + | StickyNode + | TableNode + | TableCellNode + | TextNode + | TextPathNode + | TransformGroupNode + | VectorNode + | WashiTapeNode + | WidgetNode + +export type BooleanOperationNode = { + /** + * The type of this node, represented by the string literal "BOOLEAN_OPERATION" + */ + type: 'BOOLEAN_OPERATION' + + /** + * A string enum indicating the type of boolean operation applied. + */ + booleanOperation: 'UNION' | 'INTERSECT' | 'SUBTRACT' | 'EXCLUDE' +} & IsLayerTrait & + HasBlendModeAndOpacityTrait & + HasChildrenTrait & + HasLayoutTrait & + HasGeometryTrait & + HasExportSettingsTrait & + HasEffectsTrait & + HasMaskTrait & + TransitionSourceTrait + +export type SectionNode = { + /** + * The type of this node, represented by the string literal "SECTION" + */ + type: 'SECTION' + + /** + * Whether the contents of the section are visible + */ + sectionContentsHidden: boolean +} & IsLayerTrait & + HasGeometryTrait & + HasChildrenTrait & + HasLayoutTrait & + DevStatusTrait + +export type FrameNode = { + /** + * The type of this node, represented by the string literal "FRAME" + */ + type: 'FRAME' +} & FrameTraits + +export type GroupNode = { + /** + * The type of this node, represented by the string literal "GROUP" + */ + type: 'GROUP' +} & FrameTraits + +export type ComponentNode = { + /** + * The type of this node, represented by the string literal "COMPONENT" + */ + type: 'COMPONENT' +} & FrameTraits & + ComponentPropertiesTrait + +export type ComponentSetNode = { + /** + * The type of this node, represented by the string literal "COMPONENT_SET" + */ + type: 'COMPONENT_SET' +} & FrameTraits & + ComponentPropertiesTrait + +export type VectorNode = { + /** + * The type of this node, represented by the string literal "VECTOR" + */ + type: 'VECTOR' +} & CornerRadiusShapeTraits & + AnnotationsTrait + +export type StarNode = { + /** + * The type of this node, represented by the string literal "STAR" + */ + type: 'STAR' +} & CornerRadiusShapeTraits & + AnnotationsTrait + +export type LineNode = { + /** + * The type of this node, represented by the string literal "LINE" + */ + type: 'LINE' +} & DefaultShapeTraits & + AnnotationsTrait + +export type EllipseNode = { + /** + * The type of this node, represented by the string literal "ELLIPSE" + */ + type: 'ELLIPSE' + + arcData: ArcData +} & DefaultShapeTraits & + AnnotationsTrait + +export type RegularPolygonNode = { + /** + * The type of this node, represented by the string literal "REGULAR_POLYGON" + */ + type: 'REGULAR_POLYGON' +} & CornerRadiusShapeTraits & + AnnotationsTrait + +export type RectangleNode = { + /** + * The type of this node, represented by the string literal "RECTANGLE" + */ + type: 'RECTANGLE' +} & RectangularShapeTraits + +export type TextNode = { + /** + * The type of this node, represented by the string literal "TEXT" + */ + type: 'TEXT' +} & DefaultShapeTraits & + TypePropertiesTrait & + AnnotationsTrait + +export type TextPathNode = { + /** + * The type of this node, represented by the string literal "TEXT_PATH" + */ + type: 'TEXT_PATH' +} & DefaultShapeTraits & + TextPathPropertiesTrait + +export type TableNode = { + /** + * The type of this node, represented by the string literal "TABLE" + */ + type: 'TABLE' +} & IsLayerTrait & + HasChildrenTrait & + HasLayoutTrait & + MinimalStrokesTrait & + HasEffectsTrait & + HasBlendModeAndOpacityTrait & + HasExportSettingsTrait + +export type TableCellNode = { + /** + * The type of this node, represented by the string literal "TABLE_CELL" + */ + type: 'TABLE_CELL' +} & IsLayerTrait & + MinimalFillsTrait & + HasLayoutTrait & + HasTextSublayerTrait + +export type TransformGroupNode = { + /** + * The type of this node, represented by the string literal "TRANSFORM_GROUP" + */ + type: 'TRANSFORM_GROUP' +} & FrameTraits + +export type SliceNode = { + /** + * The type of this node, represented by the string literal "SLICE" + */ + type: 'SLICE' +} & IsLayerTrait + +export type InstanceNode = { + /** + * The type of this node, represented by the string literal "INSTANCE" + */ + type: 'INSTANCE' + + /** + * ID of component that this instance came from. + */ + componentId: string + + /** + * If true, this node has been marked as exposed to its containing component or component set. + */ + isExposedInstance?: boolean + + /** + * IDs of instances that have been exposed to this node's level. + */ + exposedInstances?: string[] + + /** + * A mapping of name to `ComponentProperty` for all component properties on this instance. Each + * property has a type, value, and other optional values. + */ + componentProperties?: { [key: string]: ComponentProperty } + + /** + * An array of all of the fields directly overridden on this instance. Inherited overrides are not + * included. + */ + overrides: Overrides[] +} & FrameTraits + +export type EmbedNode = { + /** + * The type of this node, represented by the string literal "EMBED" + */ + type: 'EMBED' +} & IsLayerTrait & + HasExportSettingsTrait + +export type LinkUnfurlNode = { + /** + * The type of this node, represented by the string literal "LINK_UNFURL" + */ + type: 'LINK_UNFURL' +} & IsLayerTrait & + HasExportSettingsTrait + +export type StickyNode = { + /** + * The type of this node, represented by the string literal "STICKY" + */ + type: 'STICKY' + + /** + * If true, author name is visible. + */ + authorVisible?: boolean +} & IsLayerTrait & + HasLayoutTrait & + HasBlendModeAndOpacityTrait & + MinimalFillsTrait & + HasMaskTrait & + HasEffectsTrait & + HasExportSettingsTrait & + HasTextSublayerTrait + +export type ShapeWithTextNode = { + /** + * The type of this node, represented by the string literal "SHAPE_WITH_TEXT" + */ + type: 'SHAPE_WITH_TEXT' + + /** + * Geometric shape type. Most shape types have the same name as their tooltip but there are a few + * exceptions. ENG_DATABASE: Cylinder, ENG_QUEUE: Horizontal cylinder, ENG_FILE: File, ENG_FOLDER: + * Folder. + */ + shapeType: ShapeType +} & IsLayerTrait & + HasLayoutTrait & + HasBlendModeAndOpacityTrait & + MinimalFillsTrait & + HasMaskTrait & + HasEffectsTrait & + HasExportSettingsTrait & + HasTextSublayerTrait & + CornerTrait & + MinimalStrokesTrait + +export type ConnectorNode = { + /** + * The type of this node, represented by the string literal "CONNECTOR" + */ + type: 'CONNECTOR' + + /** + * The starting point of the connector. + */ + connectorStart: ConnectorEndpoint + + /** + * The ending point of the connector. + */ + connectorEnd: ConnectorEndpoint + + /** + * A string enum describing the end cap of the start of the connector. + */ + connectorStartStrokeCap: + | 'NONE' + | 'LINE_ARROW' + | 'TRIANGLE_ARROW' + | 'DIAMOND_FILLED' + | 'CIRCLE_FILLED' + | 'TRIANGLE_FILLED' + + /** + * A string enum describing the end cap of the end of the connector. + */ + connectorEndStrokeCap: + | 'NONE' + | 'LINE_ARROW' + | 'TRIANGLE_ARROW' + | 'DIAMOND_FILLED' + | 'CIRCLE_FILLED' + | 'TRIANGLE_FILLED' + + /** + * Connector line type. + */ + connectorLineType: ConnectorLineType + + /** + * Connector text background. + */ + textBackground?: ConnectorTextBackground +} & IsLayerTrait & + HasLayoutTrait & + HasBlendModeAndOpacityTrait & + HasEffectsTrait & + HasExportSettingsTrait & + HasTextSublayerTrait & + MinimalStrokesTrait + +export type WashiTapeNode = { + /** + * The type of this node, represented by the string literal "WASHI_TAPE" + */ + type: 'WASHI_TAPE' +} & DefaultShapeTraits + +export type WidgetNode = { + /** + * The type of this node, represented by the string literal "WIDGET" + */ + type: 'WIDGET' +} & IsLayerTrait & + HasExportSettingsTrait & + HasChildrenTrait + +/** + * An RGB color + */ +export type RGB = { + /** + * Red channel value, between 0 and 1. + */ + r: number + + /** + * Green channel value, between 0 and 1. + */ + g: number + + /** + * Blue channel value, between 0 and 1. + */ + b: number +} + +/** + * An RGBA color + */ +export type RGBA = { + /** + * Red channel value, between 0 and 1. + */ + r: number + + /** + * Green channel value, between 0 and 1. + */ + g: number + + /** + * Blue channel value, between 0 and 1. + */ + b: number + + /** + * Alpha channel value, between 0 and 1. + */ + a: number +} + +/** + * A flow starting point used when launching a prototype to enter Presentation view. + */ +export type FlowStartingPoint = { + /** + * Unique identifier specifying the frame. + */ + nodeId: string + + /** + * Name of flow. + */ + name: string +} + +/** + * A width and a height. + */ +export type Size = { + /** + * The width of a size. + */ + width: number + + /** + * The height of a size. + */ + height: number +} + +/** + * The device used to view a prototype. + */ +export type PrototypeDevice = { + type: 'NONE' | 'PRESET' | 'CUSTOM' | 'PRESENTATION' + + size?: Size + + presetIdentifier?: string + + rotation: 'NONE' | 'CCW_90' +} + +/** + * Sizing constraint for exports. + */ +export type Constraint = { + /** + * Type of constraint to apply: + * + * - `SCALE`: Scale by `value`. + * - `WIDTH`: Scale proportionally and set width to `value`. + * - `HEIGHT`: Scale proportionally and set height to `value`. + */ + type: 'SCALE' | 'WIDTH' | 'HEIGHT' + + /** + * See type property for effect of this field. + */ + value: number +} + +/** + * An export setting. + */ +export type ExportSetting = { + suffix: string + + format: 'JPG' | 'PNG' | 'SVG' | 'PDF' + + constraint: Constraint +} + +/** + * This type is a string enum with the following possible values + * + * Normal blends: + * + * - `PASS_THROUGH` (only applicable to objects with children) + * - `NORMAL` + * + * Darken: + * + * - `DARKEN` + * - `MULTIPLY` + * - `LINEAR_BURN` + * - `COLOR_BURN` + * + * Lighten: + * + * - `LIGHTEN` + * - `SCREEN` + * - `LINEAR_DODGE` + * - `COLOR_DODGE` + * + * Contrast: + * + * - `OVERLAY` + * - `SOFT_LIGHT` + * - `HARD_LIGHT` + * + * Inversion: + * + * - `DIFFERENCE` + * - `EXCLUSION` + * + * Component: + * + * - `HUE` + * - `SATURATION` + * - `COLOR` + * - `LUMINOSITY` + */ +export type BlendMode = + | 'PASS_THROUGH' + | 'NORMAL' + | 'DARKEN' + | 'MULTIPLY' + | 'LINEAR_BURN' + | 'COLOR_BURN' + | 'LIGHTEN' + | 'SCREEN' + | 'LINEAR_DODGE' + | 'COLOR_DODGE' + | 'OVERLAY' + | 'SOFT_LIGHT' + | 'HARD_LIGHT' + | 'DIFFERENCE' + | 'EXCLUSION' + | 'HUE' + | 'SATURATION' + | 'COLOR' + | 'LUMINOSITY' + +/** + * A 2d vector. + */ +export type Vector = { + /** + * X coordinate of the vector. + */ + x: number + + /** + * Y coordinate of the vector. + */ + y: number +} + +/** + * A single color stop with its position along the gradient axis, color, and bound variables if any + */ +export type ColorStop = { + /** + * Value between 0 and 1 representing position along gradient axis. + */ + position: number + + /** + * Color attached to corresponding position. + */ + color: RGBA + + /** + * The variables bound to a particular gradient stop + */ + boundVariables?: { color?: VariableAlias } +} + +/** + * A transformation matrix is standard way in computer graphics to represent translation and + * rotation. These are the top two rows of a 3x3 matrix. The bottom row of the matrix is assumed to + * be [0, 0, 1]. This is known as an affine transform and is enough to represent translation, + * rotation, and skew. + * + * The identity transform is [[1, 0, 0], [0, 1, 0]]. + * + * A translation matrix will typically look like: + * + * ;[ + * [1, 0, tx], + * [0, 1, ty], + * ] + * + * And a rotation matrix will typically look like: + * + * ;[ + * [cos(angle), sin(angle), 0], + * [-sin(angle), cos(angle), 0], + * ] + * + * Another way to think about this transform is as three vectors: + * + * - The x axis (t[0][0], t[1][0]) + * - The y axis (t[0][1], t[1][1]) + * - The translation offset (t[0][2], t[1][2]) + * + * The most common usage of the Transform matrix is the `relativeTransform property`. This + * particular usage of the matrix has a few additional restrictions. The translation offset can take + * on any value but we do enforce that the axis vectors are unit vectors (i.e. have length 1). The + * axes are not required to be at 90° angles to each other. + */ +export type Transform = number[][] + +/** + * Image filters to apply to the node. + */ +export type ImageFilters = { + exposure?: number + + contrast?: number + + saturation?: number + + temperature?: number + + tint?: number + + highlights?: number + + shadows?: number +} + +export type BasePaint = { + /** + * Is the paint enabled? + */ + visible?: boolean + + /** + * Overall opacity of paint (colors within the paint can also have opacity values which would blend + * with this) + */ + opacity?: number + + /** + * How this node blends with nodes behind it in the scene + */ + blendMode: BlendMode +} + +export type SolidPaint = { + /** + * The string literal "SOLID" representing the paint's type. Always check the `type` before reading + * other properties. + */ + type: 'SOLID' + + /** + * Solid color of the paint + */ + color: RGBA + + /** + * The variables bound to a particular field on this paint + */ + boundVariables?: { color?: VariableAlias } +} & BasePaint + +export type GradientPaint = { + /** + * The string literal representing the paint's type. Always check the `type` before reading other + * properties. + */ + type: 'GRADIENT_LINEAR' | 'GRADIENT_RADIAL' | 'GRADIENT_ANGULAR' | 'GRADIENT_DIAMOND' + + /** + * This field contains three vectors, each of which are a position in normalized object space + * (normalized object space is if the top left corner of the bounding box of the object is (0, 0) + * and the bottom right is (1,1)). The first position corresponds to the start of the gradient + * (value 0 for the purposes of calculating gradient stops), the second position is the end of the + * gradient (value 1), and the third handle position determines the width of the gradient. + */ + gradientHandlePositions: Vector[] + + /** + * Positions of key points along the gradient axis with the colors anchored there. Colors along the + * gradient are interpolated smoothly between neighboring gradient stops. + */ + gradientStops: ColorStop[] +} & BasePaint + +export type ImagePaint = { + /** + * The string literal "IMAGE" representing the paint's type. Always check the `type` before reading + * other properties. + */ + type: 'IMAGE' + + /** + * Image scaling mode. + */ + scaleMode: 'FILL' | 'FIT' | 'TILE' | 'STRETCH' + + /** + * A reference to an image embedded in this node. To download the image using this reference, use + * the `GET file images` endpoint to retrieve the mapping from image references to image URLs. + */ + imageRef: string + + /** + * Affine transform applied to the image, only present if `scaleMode` is `STRETCH` + */ + imageTransform?: Transform + + /** + * Amount image is scaled by in tiling, only present if scaleMode is `TILE`. + */ + scalingFactor?: number + + /** + * Defines what image filters have been applied to this paint, if any. If this property is not + * defined, no filters have been applied. + */ + filters?: ImageFilters + + /** + * Image rotation, in degrees. + */ + rotation?: number + + /** + * A reference to an animated GIF embedded in this node. To download the image using this reference, + * use the `GET file images` endpoint to retrieve the mapping from image references to image URLs. + */ + gifRef?: string +} & BasePaint + +export type PatternPaint = { + /** + * The string literal "PATTERN" representing the paint's type. Always check the `type` before + * reading other properties. + */ + type: 'PATTERN' + + /** + * The node id of the source node for the pattern + */ + sourceNodeId: string + + /** + * The tile type for the pattern + */ + tileType: 'RECTANGULAR' | 'HORIZONTAL_HEXAGONAL' | 'VERTICAL_HEXAGONAL' + + /** + * The scaling factor for the pattern + */ + scalingFactor: number + + /** + * The spacing for the pattern + */ + spacing: Vector + + /** + * The horizontal alignment for the pattern + */ + horizontalAlignment: 'START' | 'CENTER' | 'END' + + /** + * The vertical alignment for the pattern + */ + verticalAlignment: 'START' | 'CENTER' | 'END' +} & BasePaint + +export type Paint = SolidPaint | GradientPaint | ImagePaint | PatternPaint + +/** + * Layout constraint relative to containing Frame + */ +export type LayoutConstraint = { + /** + * Vertical constraint (relative to containing frame) as an enum: + * + * - `TOP`: Node is laid out relative to top of the containing frame + * - `BOTTOM`: Node is laid out relative to bottom of the containing frame + * - `CENTER`: Node is vertically centered relative to containing frame + * - `TOP_BOTTOM`: Both top and bottom of node are constrained relative to containing frame (node + * stretches with frame) + * - `SCALE`: Node scales vertically with containing frame + */ + vertical: 'TOP' | 'BOTTOM' | 'CENTER' | 'TOP_BOTTOM' | 'SCALE' + + /** + * Horizontal constraint (relative to containing frame) as an enum: + * + * - `LEFT`: Node is laid out relative to left of the containing frame + * - `RIGHT`: Node is laid out relative to right of the containing frame + * - `CENTER`: Node is horizontally centered relative to containing frame + * - `LEFT_RIGHT`: Both left and right of node are constrained relative to containing frame (node + * stretches with frame) + * - `SCALE`: Node scales horizontally with containing frame + */ + horizontal: 'LEFT' | 'RIGHT' | 'CENTER' | 'LEFT_RIGHT' | 'SCALE' +} + +/** + * A rectangle that expresses a bounding box in absolute coordinates. + */ +export type Rectangle = { + /** + * X coordinate of top left corner of the rectangle. + */ + x: number + + /** + * Y coordinate of top left corner of the rectangle. + */ + y: number + + /** + * Width of the rectangle. + */ + width: number + + /** + * Height of the rectangle. + */ + height: number +} + +/** + * Guides to align and place objects within a frames. + */ +export type LayoutGrid = { + /** + * Orientation of the grid as a string enum + * + * - `COLUMNS`: Vertical grid + * - `ROWS`: Horizontal grid + * - `GRID`: Square grid + */ + pattern: 'COLUMNS' | 'ROWS' | 'GRID' + + /** + * Width of column grid or height of row grid or square grid spacing. + */ + sectionSize: number + + /** + * Is the grid currently visible? + */ + visible: boolean + + /** + * Color of the grid + */ + color: RGBA + + /** + * Positioning of grid as a string enum + * + * - `MIN`: Grid starts at the left or top of the frame + * - `MAX`: Grid starts at the right or bottom of the frame + * - `STRETCH`: Grid is stretched to fit the frame + * - `CENTER`: Grid is center aligned + */ + alignment: 'MIN' | 'MAX' | 'STRETCH' | 'CENTER' + + /** + * Spacing in between columns and rows + */ + gutterSize: number + + /** + * Spacing before the first column or row + */ + offset: number + + /** + * Number of columns or rows + */ + count: number + + /** + * The variables bound to a particular field on this layout grid + */ + boundVariables?: { + gutterSize?: VariableAlias + + numSections?: VariableAlias + + sectionSize?: VariableAlias + + offset?: VariableAlias + } +} + +/** + * Base properties shared by all shadow effects + */ +export type BaseShadowEffect = { + /** + * The color of the shadow + */ + color: RGBA + + /** + * Blend mode of the shadow + */ + blendMode: BlendMode + + /** + * How far the shadow is projected in the x and y directions + */ + offset: Vector + + /** + * Radius of the blur effect (applies to shadows as well) + */ + radius: number + + /** + * The distance by which to expand (or contract) the shadow. + * + * For drop shadows, a positive `spread` value creates a shadow larger than the node, whereas a + * negative value creates a shadow smaller than the node. + * + * For inner shadows, a positive `spread` value contracts the shadow. Spread values are only + * accepted on rectangles and ellipses, or on frames, components, and instances with visible fill + * paints and `clipsContent` enabled. When left unspecified, the default value is 0. + */ + spread?: number + + /** + * Whether this shadow is visible. + */ + visible: boolean + + /** + * The variables bound to a particular field on this shadow effect + */ + boundVariables?: { + radius?: VariableAlias + + spread?: VariableAlias + + color?: VariableAlias + + offsetX?: VariableAlias + + offsetY?: VariableAlias + } +} + +export type DropShadowEffect = { + /** + * A string literal representing the effect's type. Always check the type before reading other + * properties. + */ + type: 'DROP_SHADOW' + + /** + * Whether to show the shadow behind translucent or transparent pixels + */ + showShadowBehindNode: boolean +} & BaseShadowEffect + +export type InnerShadowEffect = { + /** + * A string literal representing the effect's type. Always check the type before reading other + * properties. + */ + type?: 'INNER_SHADOW' +} & BaseShadowEffect + +export type BlurEffect = NormalBlurEffect | ProgressiveBlurEffect + +/** + * Base properties shared by all blur effects + */ +export type BaseBlurEffect = { + /** + * A string literal representing the effect's type. Always check the type before reading other + * properties. + */ + type: 'LAYER_BLUR' | 'BACKGROUND_BLUR' + + /** + * Whether this blur is active. + */ + visible: boolean + + /** + * Radius of the blur effect + */ + radius: number + + /** + * The variables bound to a particular field on this blur effect + */ + boundVariables?: { radius?: VariableAlias } +} + +export type NormalBlurEffect = { + /** + * The string literal 'NORMAL' representing the blur type. Always check the blurType before reading + * other properties. + */ + blurType?: 'NORMAL' +} & BaseBlurEffect + +export type ProgressiveBlurEffect = { + /** + * The string literal 'PROGRESSIVE' representing the blur type. Always check the blurType before + * reading other properties. + */ + blurType: 'PROGRESSIVE' + + /** + * The starting radius of the progressive blur + */ + startRadius: number + + /** + * The starting offset of the progressive blur + */ + startOffset: Vector + + /** + * The ending offset of the progressive blur + */ + endOffset: Vector +} & BaseBlurEffect + +/** + * A texture effect + */ +export type TextureEffect = { + /** + * The string literal 'TEXTURE' representing the effect's type. Always check the type before reading + * other properties. + */ + type: 'TEXTURE' + + /** + * The size of the texture effect + */ + noiseSize: number + + /** + * The radius of the texture effect + */ + radius: number + + /** + * Whether the texture is clipped to the shape + */ + clipToShape: boolean +} + +export type MonotoneNoiseEffect = { + /** + * The string literal 'MONOTONE' representing the noise type. + */ + noiseType: 'MONOTONE' +} & BaseNoiseEffect + +export type MultitoneNoiseEffect = { + /** + * The string literal 'MULTITONE' representing the noise type. + */ + noiseType: 'MULTITONE' + + /** + * The opacity of the noise effect + */ + opacity: number +} & BaseNoiseEffect + +export type DuotoneNoiseEffect = { + /** + * The string literal 'DUOTONE' representing the noise type. + */ + noiseType: 'DUOTONE' + + /** + * The secondary color of the noise effect + */ + secondaryColor: RGBA +} & BaseNoiseEffect + +/** + * A noise effect + */ +export type BaseNoiseEffect = { + /** + * The string literal 'NOISE' representing the effect's type. Always check the type before reading + * other properties. + */ + type: 'NOISE' + + /** + * Blend mode of the noise effect + */ + blendMode: BlendMode + + /** + * The size of the noise effect + */ + noiseSize: number + + /** + * The density of the noise effect + */ + density: number +} + +export type NoiseEffect = MonotoneNoiseEffect | MultitoneNoiseEffect | DuotoneNoiseEffect + +export type Effect = DropShadowEffect | InnerShadowEffect | BlurEffect | TextureEffect | NoiseEffect + +/** + * A set of properties that can be applied to nodes and published. Styles for a property can be + * created in the corresponding property's panel while editing a file. + */ +export type Style = { + /** + * The key of the style + */ + key: string + + /** + * Name of the style + */ + name: string + + /** + * Description of the style + */ + description: string + + /** + * Whether this style is a remote style that doesn't live in this file + */ + remote: boolean + + styleType: StyleType +} + +/** + * This type is a string enum with the following possible values: + * + * - `EASE_IN`: Ease in with an animation curve similar to CSS ease-in. + * - `EASE_OUT`: Ease out with an animation curve similar to CSS ease-out. + * - `EASE_IN_AND_OUT`: Ease in and then out with an animation curve similar to CSS ease-in-out. + * - `LINEAR`: No easing, similar to CSS linear. + * - `EASE_IN_BACK`: Ease in with an animation curve that moves past the initial keyframe's value and + * then accelerates as it reaches the end. + * - `EASE_OUT_BACK`: Ease out with an animation curve that starts fast, then slows and goes past the + * ending keyframe's value. + * - `EASE_IN_AND_OUT_BACK`: Ease in and then out with an animation curve that overshoots the initial + * keyframe's value, then accelerates quickly before it slows and overshoots the ending keyframes + * value. + * - `CUSTOM_CUBIC_BEZIER`: User-defined cubic bezier curve. + * - `GENTLE`: Gentle animation similar to react-spring. + * - `QUICK`: Quick spring animation, great for toasts and notifications. + * - `BOUNCY`: Bouncy spring, for delightful animations like a heart bounce. + * - `SLOW`: Slow spring, useful as a steady, natural way to scale up fullscreen content. + * - `CUSTOM_SPRING`: User-defined spring animation. + */ +export type EasingType = + | 'EASE_IN' + | 'EASE_OUT' + | 'EASE_IN_AND_OUT' + | 'LINEAR' + | 'EASE_IN_BACK' + | 'EASE_OUT_BACK' + | 'EASE_IN_AND_OUT_BACK' + | 'CUSTOM_CUBIC_BEZIER' + | 'GENTLE' + | 'QUICK' + | 'BOUNCY' + | 'SLOW' + | 'CUSTOM_SPRING' + +/** + * Individual stroke weights + */ +export type StrokeWeights = { + /** + * The top stroke weight. + */ + top: number + + /** + * The right stroke weight. + */ + right: number + + /** + * The bottom stroke weight. + */ + bottom: number + + /** + * The left stroke weight. + */ + left: number +} + +/** + * Paint metadata to override default paints. + */ +export type PaintOverride = { + /** + * Paints applied to characters. + */ + fills?: Paint[] + + /** + * ID of style node, if any, that this inherits fill data from. + */ + inheritFillStyleId?: string +} + +/** + * Defines a single path + */ +export type Path = { + /** + * A series of path commands that encodes how to draw the path. + */ + path: string + + /** + * The winding rule for the path (same as in SVGs). This determines whether a given point in space + * is inside or outside the path. + */ + windingRule: 'NONZERO' | 'EVENODD' + + /** + * If there is a per-region fill, this refers to an ID in the `fillOverrideTable`. + */ + overrideID?: number +} + +/** + * Information about the arc properties of an ellipse. 0° is the x axis and increasing angles rotate + * clockwise. + */ +export type ArcData = { + /** + * Start of the sweep in radians. + */ + startingAngle: number + + /** + * End of the sweep in radians. + */ + endingAngle: number + + /** + * Inner radius value between 0 and 1 + */ + innerRadius: number +} + +/** + * A link to either a URL or another frame (node) in the document. + */ +export type Hyperlink = { + /** + * The type of hyperlink. Can be either `URL` or `NODE`. + */ + type: 'URL' | 'NODE' + + /** + * The URL that the hyperlink points to, if `type` is `URL`. + */ + url?: string + + /** + * The ID of the node that the hyperlink points to, if `type` is `NODE`. + */ + nodeID?: string +} + +export type BaseTypeStyle = { + /** + * Font family of text (standard name). + */ + fontFamily?: string + + /** + * PostScript font name. + */ + fontPostScriptName?: string | null + + /** + * Describes visual weight or emphasis, such as Bold or Italic. + */ + fontStyle?: string + + /** + * Whether or not text is italicized. + */ + italic?: boolean + + /** + * Numeric font weight. + */ + fontWeight?: number + + /** + * Font size in px. + */ + fontSize?: number + + /** + * Text casing applied to the node, default is the original casing. + */ + textCase?: 'UPPER' | 'LOWER' | 'TITLE' | 'SMALL_CAPS' | 'SMALL_CAPS_FORCED' + + /** + * Horizontal text alignment as string enum. + */ + textAlignHorizontal?: 'LEFT' | 'RIGHT' | 'CENTER' | 'JUSTIFIED' + + /** + * Vertical text alignment as string enum. + */ + textAlignVertical?: 'TOP' | 'CENTER' | 'BOTTOM' + + /** + * Space between characters in px. + */ + letterSpacing?: number + + /** + * An array of fill paints applied to the characters. + */ + fills?: Paint[] + + /** + * Link to a URL or frame. + */ + hyperlink?: Hyperlink + + /** + * A map of OpenType feature flags to 1 or 0, 1 if it is enabled and 0 if it is disabled. Note that + * some flags aren't reflected here. For example, SMCP (small caps) is still represented by the + * `textCase` field. + */ + opentypeFlags?: { [key: string]: number } + + /** + * Indicates how the font weight was overridden when there is a text style override. + */ + semanticWeight?: 'BOLD' | 'NORMAL' + + /** + * Indicates how the font style was overridden when there is a text style override. + */ + semanticItalic?: 'ITALIC' | 'NORMAL' +} + +export type TypeStyle = { + /** + * Space between paragraphs in px, 0 if not present. + */ + paragraphSpacing?: number + + /** + * Paragraph indentation in px, 0 if not present. + */ + paragraphIndent?: number + + /** + * Space between list items in px, 0 if not present. + */ + listSpacing?: number + + /** + * Text decoration applied to the node, default is none. + */ + textDecoration?: 'NONE' | 'STRIKETHROUGH' | 'UNDERLINE' + + /** + * Dimensions along which text will auto resize, default is that the text does not auto-resize. + * TRUNCATE means that the text will be shortened and trailing text will be replaced with "…" if the + * text contents is larger than the bounds. `TRUNCATE` as a return value is deprecated and will be + * removed in a future version. Read from `textTruncation` instead. + */ + textAutoResize?: 'NONE' | 'WIDTH_AND_HEIGHT' | 'HEIGHT' | 'TRUNCATE' + + /** + * Whether this text node will truncate with an ellipsis when the text contents is larger than the + * text node. + */ + textTruncation?: 'DISABLED' | 'ENDING' + + /** + * When `textTruncation: "ENDING"` is set, `maxLines` determines how many lines a text node can grow + * to before it truncates. + */ + maxLines?: number + + /** + * Line height in px. + */ + lineHeightPx?: number + + /** + * Line height as a percentage of normal line height. This is deprecated; in a future version of the + * API only lineHeightPx and lineHeightPercentFontSize will be returned. + */ + lineHeightPercent?: number + + /** + * Line height as a percentage of the font size. Only returned when `lineHeightPercent` (deprecated) + * is not 100. + */ + lineHeightPercentFontSize?: number + + /** + * The unit of the line height value specified by the user. + */ + lineHeightUnit?: 'PIXELS' | 'FONT_SIZE_%' | 'INTRINSIC_%' + + /** + * Whether or not this style has overrides over a text style. The possible fields to override are + * semanticWeight, semanticItalic, hyperlink, and textDecoration. If this is true, then those fields + * are overrides if present. + */ + isOverrideOverTextStyle?: boolean + + /** + * The variables bound to a particular field on this style + */ + boundVariables?: { + fontFamily?: VariableAlias + + fontSize?: VariableAlias + + fontStyle?: VariableAlias + + fontWeight?: VariableAlias + + letterSpacing?: VariableAlias + + lineHeight?: VariableAlias + + paragraphSpacing?: VariableAlias + + paragraphIndent?: VariableAlias + } +} & BaseTypeStyle + +export type TextPathTypeStyle = { + /** + * Whether or not this style has overrides over a text style. The possible fields to override are + * semanticWeight, semanticItalic, and hyperlink. If this is true, then those fields are overrides + * if present. + */ + isOverrideOverTextStyle?: boolean + + /** + * The variables bound to a particular field on this style + */ + boundVariables?: { + fontFamily?: VariableAlias + + fontSize?: VariableAlias + + fontStyle?: VariableAlias + + fontWeight?: VariableAlias + + letterSpacing?: VariableAlias + } +} & BaseTypeStyle + +/** + * Component property type. + */ +export type ComponentPropertyType = 'BOOLEAN' | 'INSTANCE_SWAP' | 'TEXT' | 'VARIANT' + +/** + * Instance swap preferred value. + */ +export type InstanceSwapPreferredValue = { + /** + * Type of node for this preferred value. + */ + type: 'COMPONENT' | 'COMPONENT_SET' + + /** + * Key of this component or component set. + */ + key: string +} + +/** + * A property of a component. + */ +export type ComponentPropertyDefinition = { + /** + * Type of this component property. + */ + type: ComponentPropertyType + + /** + * Initial value of this property for instances. + */ + defaultValue: boolean | string + + /** + * All possible values for this property. Only exists on VARIANT properties. + */ + variantOptions?: string[] + + /** + * Preferred values for this property. Only applicable if type is `INSTANCE_SWAP`. + */ + preferredValues?: InstanceSwapPreferredValue[] +} + +/** + * A property of a component. + */ +export type ComponentProperty = { + /** + * Type of this component property. + */ + type: ComponentPropertyType + + /** + * Value of the property for this component instance. + */ + value: boolean | string + + /** + * Preferred values for this property. Only applicable if type is `INSTANCE_SWAP`. + */ + preferredValues?: InstanceSwapPreferredValue[] + + /** + * The variables bound to a particular field on this component property + */ + boundVariables?: { value?: VariableAlias } +} + +/** + * Fields directly overridden on an instance. Inherited overrides are not included. + */ +export type Overrides = { + /** + * A unique ID for a node. + */ + id: string + + /** + * An array of properties. + */ + overriddenFields: string[] +} + +/** + * Geometric shape type. + */ +export type ShapeType = + | 'SQUARE' + | 'ELLIPSE' + | 'ROUNDED_RECTANGLE' + | 'DIAMOND' + | 'TRIANGLE_UP' + | 'TRIANGLE_DOWN' + | 'PARALLELOGRAM_RIGHT' + | 'PARALLELOGRAM_LEFT' + | 'ENG_DATABASE' + | 'ENG_QUEUE' + | 'ENG_FILE' + | 'ENG_FOLDER' + | 'TRAPEZOID' + | 'PREDEFINED_PROCESS' + | 'SHIELD' + | 'DOCUMENT_SINGLE' + | 'DOCUMENT_MULTIPLE' + | 'MANUAL_INPUT' + | 'HEXAGON' + | 'CHEVRON' + | 'PENTAGON' + | 'OCTAGON' + | 'STAR' + | 'PLUS' + | 'ARROW_LEFT' + | 'ARROW_RIGHT' + | 'SUMMING_JUNCTION' + | 'OR' + | 'SPEECH_BUBBLE' + | 'INTERNAL_STORAGE' + +/** + * Stores canvas location for a connector start/end point. + */ +export type ConnectorEndpoint = + | { + /** + * Node ID that this endpoint attaches to. + */ + endpointNodeId?: string + + /** + * The position of the endpoint relative to the node. + */ + position?: Vector + } + | { + /** + * Node ID that this endpoint attaches to. + */ + endpointNodeId?: string + + /** + * The magnet type is a string enum. + */ + magnet?: 'AUTO' | 'TOP' | 'BOTTOM' | 'LEFT' | 'RIGHT' | 'CENTER' + } + +/** + * Connector line type. + */ +export type ConnectorLineType = 'STRAIGHT' | 'ELBOWED' + +export type ConnectorTextBackground = CornerTrait & MinimalFillsTrait + +/** + * A description of a main component. Helps you identify which component instances are attached to. + */ +export type Component = { + /** + * The key of the component + */ + key: string + + /** + * Name of the component + */ + name: string + + /** + * The description of the component as entered in the editor + */ + description: string + + /** + * The ID of the component set if the component belongs to one + */ + componentSetId?: string + + /** + * An array of documentation links attached to this component + */ + documentationLinks: DocumentationLink[] + + /** + * Whether this component is a remote component that doesn't live in this file + */ + remote: boolean +} + +/** + * A description of a component set, which is a node containing a set of variants of a component. + */ +export type ComponentSet = { + /** + * The key of the component set + */ + key: string + + /** + * Name of the component set + */ + name: string + + /** + * The description of the component set as entered in the editor + */ + description: string + + /** + * An array of documentation links attached to this component set + */ + documentationLinks?: DocumentationLink[] + + /** + * Whether this component set is a remote component set that doesn't live in this file + */ + remote?: boolean +} + +/** + * Represents a link to documentation for a component or component set. + */ +export type DocumentationLink = { + /** + * Should be a valid URI (e.g. https://www.figma.com). + */ + uri: string +} + +/** + * Contains a variable alias + */ +export type VariableAlias = { + type: 'VARIABLE_ALIAS' + + /** + * The id of the variable that the current variable is aliased to. This variable can be a local or + * remote variable, and both can be retrieved via the GET /v1/files/:file_key/variables/local + * endpoint. + */ + id: string +} + +/** + * An interaction in the Figma viewer, containing a trigger and one or more actions. + */ +export type Interaction = { + /** + * The user event that initiates the interaction. + */ + trigger: Trigger | null + + /** + * The actions that are performed when the trigger is activated. + */ + actions?: Action[] +} + +/** + * The `"ON_HOVER"` and `"ON_PRESS"` trigger types revert the navigation when the trigger is + * finished (the result is temporary). `"MOUSE_ENTER"`, `"MOUSE_LEAVE"`, `"MOUSE_UP"` and + * `"MOUSE_DOWN"` are permanent, one-way navigation. The `delay` parameter requires the trigger to + * be held for a certain duration of time before the action occurs. Both `timeout` and `delay` + * values are in milliseconds. The `"ON_MEDIA_HIT"` and `"ON_MEDIA_END"` trigger types can only + * trigger from a video. They fire when a video reaches a certain time or ends. The `timestamp` + * value is in seconds. + */ +export type Trigger = + | { type: 'ON_CLICK' | 'ON_HOVER' | 'ON_PRESS' | 'ON_DRAG' } + | AfterTimeoutTrigger + | { + type: 'MOUSE_ENTER' | 'MOUSE_LEAVE' | 'MOUSE_UP' | 'MOUSE_DOWN' + + delay: number + + /** + * Whether this is a [deprecated + * version](https://help.figma.com/hc/en-us/articles/360040035834-Prototype-triggers#h_01HHN04REHJNP168R26P1CMP0A) + * of the trigger that was left unchanged for backwards compatibility. If not present, the trigger + * is the latest version. + */ + deprecatedVersion?: boolean + } + | OnKeyDownTrigger + | OnMediaHitTrigger + | { type: 'ON_MEDIA_END' } + +export type AfterTimeoutTrigger = { + type: 'AFTER_TIMEOUT' + + timeout: number +} + +export type OnKeyDownTrigger = { + type: 'ON_KEY_DOWN' + + device: 'KEYBOARD' | 'XBOX_ONE' | 'PS4' | 'SWITCH_PRO' | 'UNKNOWN_CONTROLLER' + + keyCodes: number[] +} + +export type OnMediaHitTrigger = { + type: 'ON_MEDIA_HIT' + + mediaHitTime: number +} + +/** + * An action that is performed when a trigger is activated. + */ +export type Action = + | { type: 'BACK' | 'CLOSE' } + | OpenURLAction + | UpdateMediaRuntimeAction + | SetVariableAction + | SetVariableModeAction + | ConditionalAction + | NodeAction + +/** + * An action that opens a URL. + */ +export type OpenURLAction = { + type: 'URL' + + url: string +} + +/** + * An action that affects a video node in the Figma viewer. For example, to play, pause, or skip. + */ +export type UpdateMediaRuntimeAction = + | { + type: 'UPDATE_MEDIA_RUNTIME' + + destinationId: string | null + + mediaAction: 'PLAY' | 'PAUSE' | 'TOGGLE_PLAY_PAUSE' | 'MUTE' | 'UNMUTE' | 'TOGGLE_MUTE_UNMUTE' + } + | { + type: 'UPDATE_MEDIA_RUNTIME' + + destinationId?: string | null + + mediaAction: 'SKIP_FORWARD' | 'SKIP_BACKWARD' + + amountToSkip: number + } + | { + type: 'UPDATE_MEDIA_RUNTIME' + + destinationId?: string | null + + mediaAction: 'SKIP_TO' + + newTimestamp: number + } + +/** + * An action that navigates to a specific node in the Figma viewer. + */ +export type NodeAction = { + type: 'NODE' + + destinationId: string | null + + navigation: Navigation + + transition: Transition | null + + /** + * Whether the scroll offsets of any scrollable elements in the current screen or overlay are + * preserved when navigating to the destination. This is applicable only if the layout of both the + * current frame and its destination are the same. + */ + preserveScrollPosition?: boolean + + /** + * Applicable only when `navigation` is `"OVERLAY"` and the destination is a frame with + * `overlayPosition` equal to `"MANUAL"`. This value represents the offset by which the overlay is + * opened relative to this node. + */ + overlayRelativePosition?: Vector + + /** + * When true, all videos within the destination frame will reset their memorized playback position + * to 00:00 before starting to play. + */ + resetVideoPosition?: boolean + + /** + * Whether the scroll offsets of any scrollable elements in the current screen or overlay reset when + * navigating to the destination. This is applicable only if the layout of both the current frame + * and its destination are the same. + */ + resetScrollPosition?: boolean + + /** + * Whether the state of any interactive components in the current screen or overlay reset when + * navigating to the destination. This is applicable if there are interactive components in the + * destination frame. + */ + resetInteractiveComponents?: boolean +} + +/** + * The method of navigation. The possible values are: + * + * - `"NAVIGATE"`: Replaces the current screen with the destination, also closing all overlays. + * - `"OVERLAY"`: Opens the destination as an overlay on the current screen. + * - `"SWAP"`: On an overlay, replaces the current (topmost) overlay with the destination. On a + * top-level frame, behaves the same as `"NAVIGATE"` except that no entry is added to the + * navigation history. + * - `"SCROLL_TO"`: Scrolls to the destination on the current screen. + * - `"CHANGE_TO"`: Changes the closest ancestor instance of source node to the specified variant. + */ +export type Navigation = 'NAVIGATE' | 'SWAP' | 'OVERLAY' | 'SCROLL_TO' | 'CHANGE_TO' + +export type Transition = SimpleTransition | DirectionalTransition + +/** + * Describes an animation used when navigating in a prototype. + */ +export type SimpleTransition = { + type: 'DISSOLVE' | 'SMART_ANIMATE' | 'SCROLL_ANIMATE' + + /** + * The duration of the transition in milliseconds. + */ + duration: number + + /** + * The easing curve of the transition. + */ + easing: Easing +} + +/** + * Describes an animation used when navigating in a prototype. + */ +export type DirectionalTransition = { + type: 'MOVE_IN' | 'MOVE_OUT' | 'PUSH' | 'SLIDE_IN' | 'SLIDE_OUT' + + direction: 'LEFT' | 'RIGHT' | 'TOP' | 'BOTTOM' + + /** + * The duration of the transition in milliseconds. + */ + duration: number + + /** + * The easing curve of the transition. + */ + easing: Easing + + /** + * When the transition `type` is `"SMART_ANIMATE"` or when `matchLayers` is `true`, then the + * transition will be performed using smart animate, which attempts to match corresponding layers an + * interpolate other properties during the animation. + */ + matchLayers?: boolean +} + +/** + * Describes an easing curve. + */ +export type Easing = { + /** + * The type of easing curve. + */ + type: EasingType + + /** + * A cubic bezier curve that defines the easing. + */ + easingFunctionCubicBezier?: { + /** + * The x component of the first control point. + */ + x1: number + + /** + * The y component of the first control point. + */ + y1: number + + /** + * The x component of the second control point. + */ + x2: number + + /** + * The y component of the second control point. + */ + y2: number + } + + /** + * A spring function that defines the easing. + */ + easingFunctionSpring?: { + mass: number + + stiffness: number + + damping: number + } +} + +/** + * Sets a variable to a specific value. + */ +export type SetVariableAction = { + type: 'SET_VARIABLE' + + variableId: string | null + + variableValue?: VariableData +} + +/** + * Sets a variable to a specific mode. + */ +export type SetVariableModeAction = { + type: 'SET_VARIABLE_MODE' + + variableCollectionId?: string | null + + variableModeId?: string | null +} + +/** + * Checks if a condition is met before performing certain actions by using an if/else conditional + * statement. + */ +export type ConditionalAction = { + type: 'CONDITIONAL' + + conditionalBlocks: ConditionalBlock[] +} + +/** + * A value to set a variable to during prototyping. + */ +export type VariableData = { + type?: VariableDataType + + resolvedType?: VariableResolvedDataType + + value?: boolean | number | string | RGB | RGBA | VariableAlias | Expression +} + +/** + * Defines the types of data a VariableData object can hold + */ +export type VariableDataType = + | 'BOOLEAN' + | 'FLOAT' + | 'STRING' + | 'COLOR' + | 'VARIABLE_ALIAS' + | 'EXPRESSION' + +/** + * Defines the types of data a VariableData object can eventually equal + */ +export type VariableResolvedDataType = 'BOOLEAN' | 'FLOAT' | 'STRING' | 'COLOR' + +/** + * Defines the [Expression](https://help.figma.com/hc/en-us/articles/15253194385943) object, which + * contains a list of `VariableData` objects strung together by operators (`ExpressionFunction`). + */ +export type Expression = { + expressionFunction: ExpressionFunction + + expressionArguments: VariableData[] +} + +/** + * Defines the list of operators available to use in an Expression. + */ +export type ExpressionFunction = + | 'ADDITION' + | 'SUBTRACTION' + | 'MULTIPLICATION' + | 'DIVISION' + | 'EQUALS' + | 'NOT_EQUAL' + | 'LESS_THAN' + | 'LESS_THAN_OR_EQUAL' + | 'GREATER_THAN' + | 'GREATER_THAN_OR_EQUAL' + | 'AND' + | 'OR' + | 'VAR_MODE_LOOKUP' + | 'NEGATE' + | 'NOT' + +/** + * Either the if or else conditional blocks. The if block contains a condition to check. If that + * condition is met then it will run those list of actions, else it will run the actions in the else + * block. + */ +export type ConditionalBlock = { + condition?: VariableData + + actions: Action[] +} + +/** + * A pinned distance between two nodes in Dev Mode + */ +export type Measurement = { + id: string + + start: MeasurementStartEnd + + end: MeasurementStartEnd + + offset: MeasurementOffsetInner | MeasurementOffsetOuter + + /** + * When manually overridden, the displayed value of the measurement + */ + freeText?: string +} + +/** + * The node and side a measurement is pinned to + */ +export type MeasurementStartEnd = { + nodeId: string + + side: 'TOP' | 'RIGHT' | 'BOTTOM' | 'LEFT' +} + +/** + * Measurement offset relative to the inside of the start node + */ +export type MeasurementOffsetInner = { + type: 'INNER' + + relative: number +} + +/** + * Measurement offset relative to the outside of the start node + */ +export type MeasurementOffsetOuter = { + type: 'OUTER' + + fixed: number +} + +/** + * Position of a comment relative to the frame to which it is attached. + */ +export type FrameOffset = { + /** + * Unique id specifying the frame. + */ + node_id: string + + /** + * 2D vector offset within the frame from the top-left corner. + */ + node_offset: Vector +} + +/** + * Position of a region comment on the canvas. + */ +export type Region = { + /** + * X coordinate of the position. + */ + x: number + + /** + * Y coordinate of the position. + */ + y: number + + /** + * The height of the comment region. Must be greater than 0. + */ + region_height: number + + /** + * The width of the comment region. Must be greater than 0. + */ + region_width: number + + /** + * The corner of the comment region to pin to the node's corner as a string enum. + */ + comment_pin_corner?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' +} + +/** + * Position of a region comment relative to the frame to which it is attached. + */ +export type FrameOffsetRegion = { + /** + * Unique id specifying the frame. + */ + node_id: string + + /** + * 2D vector offset within the frame from the top-left corner. + */ + node_offset: Vector + + /** + * The height of the comment region. Must be greater than 0. + */ + region_height: number + + /** + * The width of the comment region. Must be greater than 0. + */ + region_width: number + + /** + * The corner of the comment region to pin to the node's corner as a string enum. + */ + comment_pin_corner?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' +} + +/** + * A comment or reply left by a user. + */ +export type Comment = { + /** + * Unique identifier for comment. + */ + id: string + + /** + * Positioning information of the comment. Includes information on the location of the comment pin, + * which is either the absolute coordinates on the canvas or a relative offset within a frame. If + * the comment is a region, it will also contain the region height, width, and position of the + * anchor in regards to the region. + */ + client_meta: Vector | FrameOffset | Region | FrameOffsetRegion + + /** + * The file in which the comment lives + */ + file_key: string + + /** + * If present, the id of the comment to which this is the reply + */ + parent_id?: string + + /** + * The user who left the comment + */ + user: User + + /** + * The UTC ISO 8601 time at which the comment was left + */ + created_at: string + + /** + * If set, the UTC ISO 8601 time the comment was resolved + */ + resolved_at?: string | null + + /** + * The content of the comment + */ + message: string + + /** + * Only set for top level comments. The number displayed with the comment in the UI + */ + order_id: string | null + + /** + * An array of reactions to the comment + */ + reactions: Reaction[] +} + +/** + * A reaction left by a user. + */ +export type Reaction = { + /** + * The user who left the reaction. + */ + user: User + + emoji: Emoji + + /** + * The UTC ISO 8601 time at which the reaction was left. + */ + created_at: string +} + +/** + * The emoji type of reaction as shortcode (e.g. `:heart:`, `:+1::skin-tone-2:`). The list of + * accepted emoji shortcodes can be found in [this + * file](https://raw.githubusercontent.com/missive/emoji-mart/main/packages/emoji-mart-data/sets/14/native.json) + * under the top-level emojis and aliases fields, with optional skin tone modifiers when + * applicable. + */ +export type Emoji = string + +/** + * A description of a user. + */ +export type User = { + /** + * Unique stable id of the user. + */ + id: string + + /** + * Name of the user. + */ + handle: string + + /** + * URL link to the user's profile image. + */ + img_url: string +} + +/** + * Data on the frame a component resides in. + */ +export type FrameInfo = { + /** + * The ID of the frame node within the file. + */ + nodeId?: string + + /** + * The name of the frame node. + */ + name?: string + + /** + * The background color of the frame node. + */ + backgroundColor?: string + + /** + * The ID of the page containing the frame node. + */ + pageId: string + + /** + * The name of the page containing the frame node. + */ + pageName: string + + /** + * Deprecated - Use containingComponentSet instead. + * + * @deprecated + */ + containingStateGroup?: { + /** + * The ID of the state group node. + */ + nodeId?: string + + /** + * The name of the state group node. + */ + name?: string + } | null + + /** + * The component set node that contains the frame node. + */ + containingComponentSet?: { + /** + * The ID of the component set node. + */ + nodeId?: string + + /** + * The name of the component set node. + */ + name?: string + } | null +} + +/** + * An arrangement of published UI elements that can be instantiated across figma files. + */ +export type PublishedComponent = { + /** + * The unique identifier for the component. + */ + key: string + + /** + * The unique identifier of the Figma file that contains the component. + */ + file_key: string + + /** + * The unique identifier of the component node within the Figma file. + */ + node_id: string + + /** + * A URL to a thumbnail image of the component. + */ + thumbnail_url?: string + + /** + * The name of the component. + */ + name: string + + /** + * The description of the component as entered by the publisher. + */ + description: string + + /** + * The UTC ISO 8601 time when the component was created. + */ + created_at: string + + /** + * The UTC ISO 8601 time when the component was last updated. + */ + updated_at: string + + /** + * The user who last updated the component. + */ + user: User + + /** + * The containing frame of the component. + */ + containing_frame?: FrameInfo +} + +/** + * A node containing a set of variants of a component. + */ +export type PublishedComponentSet = { + /** + * The unique identifier for the component set. + */ + key: string + + /** + * The unique identifier of the Figma file that contains the component set. + */ + file_key: string + + /** + * The unique identifier of the component set node within the Figma file. + */ + node_id: string + + /** + * A URL to a thumbnail image of the component set. + */ + thumbnail_url?: string + + /** + * The name of the component set. + */ + name: string + + /** + * The description of the component set as entered by the publisher. + */ + description: string + + /** + * The UTC ISO 8601 time when the component set was created. + */ + created_at: string + + /** + * The UTC ISO 8601 time when the component set was last updated. + */ + updated_at: string + + /** + * The user who last updated the component set. + */ + user: User + + /** + * The containing frame of the component set. + */ + containing_frame?: FrameInfo +} + +/** + * The type of style + */ +export type StyleType = 'FILL' | 'TEXT' | 'EFFECT' | 'GRID' + +/** + * A set of published properties that can be applied to nodes. + */ +export type PublishedStyle = { + /** + * The unique identifier for the style + */ + key: string + + /** + * The unique identifier of the Figma file that contains the style. + */ + file_key: string + + /** + * ID of the style node within the figma file + */ + node_id: string + + style_type: StyleType + + /** + * A URL to a thumbnail image of the style. + */ + thumbnail_url?: string + + /** + * The name of the style. + */ + name: string + + /** + * The description of the style as entered by the publisher. + */ + description: string + + /** + * The UTC ISO 8601 time when the style was created. + */ + created_at: string + + /** + * The UTC ISO 8601 time when the style was last updated. + */ + updated_at: string + + /** + * The user who last updated the style. + */ + user: User + + /** + * A user specified order number by which the style can be sorted. + */ + sort_position: string +} + +/** + * A Project can be identified by both the Project name, and the Project ID. + */ +export type Project = { + /** + * The ID of the project. + */ + id: string + + /** + * The name of the project. + */ + name: string +} + +/** + * A version of a file + */ +export type Version = { + /** + * Unique identifier for version + */ + id: string + + /** + * The UTC ISO 8601 time at which the version was created + */ + created_at: string + + /** + * The label given to the version in the editor + */ + label: string | null + + /** + * The description of the version as entered in the editor + */ + description: string | null + + /** + * The user that created the version + */ + user: User + + /** + * A URL to a thumbnail image of the file version. + */ + thumbnail_url?: string +} + +/** + * A description of an HTTP webhook (from Figma back to your application) + */ +export type WebhookV2 = { + /** + * The ID of the webhook + */ + id: string + + /** + * The event this webhook triggers on + */ + event_type: WebhookV2Event + + /** + * The team id you are subscribed to for updates. This is deprecated, use context and context_id + * instead + * + * @deprecated + */ + team_id: string + + /** + * The type of context this webhook is attached to. The value will be "PROJECT", "TEAM", or "FILE" + */ + context: string + + /** + * The ID of the context this webhook is attached to + */ + context_id: string + + /** + * The plan API ID of the team or organization where this webhook was created + */ + plan_api_id: string + + /** + * The current status of the webhook + */ + status: WebhookV2Status + + /** + * The client ID of the OAuth application that registered this webhook, if any + */ + client_id: string | null + + /** + * The passcode that will be passed back to the webhook endpoint. For security, when using the GET + * endpoints, the value is an empty string + */ + passcode: string + + /** + * The endpoint that will be hit when the webhook is triggered + */ + endpoint: string + + /** + * Optional user-provided description or name for the webhook. This is provided to help make + * maintaining a number of webhooks more convenient. Max length 140 characters. + */ + description: string | null +} + +/** + * An enum representing the possible events that a webhook can subscribe to + */ +export type WebhookV2Event = + | 'PING' + | 'FILE_UPDATE' + | 'FILE_VERSION_UPDATE' + | 'FILE_DELETE' + | 'LIBRARY_PUBLISH' + | 'FILE_COMMENT' + | 'DEV_MODE_STATUS_UPDATE' + +/** + * An enum representing the possible statuses you can set a webhook to: + * + * - `ACTIVE`: The webhook is healthy and receive all events + * - `PAUSED`: The webhook is paused and will not receive any events + */ +export type WebhookV2Status = 'ACTIVE' | 'PAUSED' + +/** + * Information regarding the most recent interactions sent to a webhook endpoint + */ +export type WebhookV2Request = { + /** + * The ID of the webhook the requests were sent to + */ + webhook_id: string + + request_info: WebhookV2RequestInfo + + response_info: WebhookV2ResponseInfo + + /** + * Error message for this request. NULL if no error occurred + */ + error_msg: string | null +} + +/** + * Information regarding the request sent to a webhook endpoint + */ +export type WebhookV2RequestInfo = { + /** + * The ID of the webhook + */ + id: string + + /** + * The actual endpoint the request was sent to + */ + endpoint: string + + /** + * The contents of the request that was sent to the endpoint + */ + payload: object + + /** + * UTC ISO 8601 timestamp of when the request was sent + */ + sent_at: string +} + +/** + * Information regarding the reply sent back from a webhook endpoint + */ +export type WebhookV2ResponseInfo = object | null + +/** + * An object representing the library item information in the payload of the `LIBRARY_PUBLISH` event + */ +export type LibraryItemData = { + /** + * Unique identifier for the library item + */ + key: string + + /** + * Name of the library item + */ + name: string +} + +/** + * An object representing a fragment of a comment left by a user, used in the payload of the + * `FILE_COMMENT` event. Note only ONE of the fields below will be set + */ +export type CommentFragment = { + /** + * Comment text that is set if a fragment is text based + */ + text?: string + + /** + * User id that is set if a fragment refers to a user mention + */ + mention?: string +} + +export type WebhookBasePayload = { + /** + * The passcode specified when the webhook was created, should match what was initially provided + */ + passcode: string + + /** + * UTC ISO 8601 timestamp of when the event was triggered. + */ + timestamp: string + + /** + * The id of the webhook that caused the callback + */ + webhook_id: string +} + +export type WebhookPingPayload = WebhookBasePayload & { event_type: 'PING' } + +export type WebhookFileUpdatePayload = WebhookBasePayload & { + event_type: 'FILE_UPDATE' + + /** + * The key of the file that was updated + */ + file_key: string + + /** + * The name of the file that was updated + */ + file_name: string +} + +export type WebhookFileDeletePayload = WebhookBasePayload & { + event_type: 'FILE_DELETE' + + /** + * The key of the file that was deleted + */ + file_key: string + + /** + * The name of the file that was deleted + */ + file_name: string + + /** + * The user that deleted the file and triggered this event + */ + triggered_by: User +} + +export type WebhookFileVersionUpdatePayload = WebhookBasePayload & { + event_type: 'FILE_VERSION_UPDATE' + + /** + * UTC ISO 8601 timestamp of when the version was created + */ + created_at: string + + /** + * Description of the version in the version history + */ + description?: string + + /** + * The key of the file that was updated + */ + file_key: string + + /** + * The name of the file that was updated + */ + file_name: string + + /** + * The user that created the named version and triggered this event + */ + triggered_by: User + + /** + * ID of the published version + */ + version_id: string +} + +export type WebhookLibraryPublishPayload = WebhookBasePayload & { + event_type: 'LIBRARY_PUBLISH' + + /** + * Components that were created by the library publish + */ + created_components: LibraryItemData[] + + /** + * Styles that were created by the library publish + */ + created_styles: LibraryItemData[] + + /** + * Variables that were created by the library publish + */ + created_variables: LibraryItemData[] + + /** + * Components that were modified by the library publish + */ + modified_components: LibraryItemData[] + + /** + * Styles that were modified by the library publish + */ + modified_styles: LibraryItemData[] + + /** + * Variables that were modified by the library publish + */ + modified_variables: LibraryItemData[] + + /** + * Components that were deleted by the library publish + */ + deleted_components: LibraryItemData[] + + /** + * Styles that were deleted by the library publish + */ + deleted_styles: LibraryItemData[] + + /** + * Variables that were deleted by the library publish + */ + deleted_variables: LibraryItemData[] + + /** + * Description of the library publish + */ + description?: string + + /** + * The key of the file that was published + */ + file_key: string + + /** + * The name of the file that was published + */ + file_name: string + + /** + * The library item that was published + */ + library_item: LibraryItemData + + /** + * The user that published the library and triggered this event + */ + triggered_by: User +} + +export type WebhookFileCommentPayload = WebhookBasePayload & { + event_type: 'FILE_COMMENT' + + /** + * Contents of the comment itself + */ + comment: CommentFragment[] + + /** + * Unique identifier for comment + */ + comment_id: string + + /** + * The UTC ISO 8601 time at which the comment was left + */ + created_at: string + + /** + * The key of the file that was commented on + */ + file_key: string + + /** + * The name of the file that was commented on + */ + file_name: string + + /** + * Users that were mentioned in the comment + */ + mentions?: User[] + + /** + * The user that made the comment and triggered this event + */ + triggered_by: User +} + +export type WebhookDevModeStatusUpdatePayload = WebhookBasePayload & { + event_type: 'DEV_MODE_STATUS_UPDATE' + + /** + * The key of the file that was updated + */ + file_key: string + + /** + * The name of the file that was updated + */ + file_name: string + + /** + * The id of the node where the Dev Mode status changed. For example, "43:2" + */ + node_id: string + + /** + * An array of related links that have been applied to the layer in the file + */ + related_links: DevResource[] + + /** + * The Dev Mode status. Either "NONE", "READY_FOR_DEV", or "COMPLETED" + */ + status: string + + /** + * The user that made the status change and triggered the event + */ + triggered_by: User +} + +/** + * A Figma user + */ +export type ActivityLogUserEntity = { + /** + * The type of entity. + */ + type: 'user' + + /** + * Unique stable id of the user. + */ + id: string + + /** + * Name of the user. + */ + name: string + + /** + * Email associated with the user's account. + */ + email: string +} + +/** + * A Figma Design or FigJam file + */ +export type ActivityLogFileEntity = { + /** + * The type of entity. + */ + type: 'file' + + /** + * Unique identifier of the file. + */ + key: string + + /** + * Name of the file. + */ + name: string + + /** + * Indicates if the object is a file on Figma Design or FigJam. + */ + editor_type: 'figma' | 'figjam' + + /** + * Access policy for users who have the link to the file. + */ + link_access: 'view' | 'edit' | 'org_view' | 'org_edit' | 'inherit' + + /** + * Access policy for users who have the link to the file's prototype. + */ + proto_link_access: 'view' | 'org_view' | 'inherit' +} + +/** + * A file branch that diverges from and can be merged back into the main file + */ +export type ActivityLogFileRepoEntity = { + /** + * The type of entity. + */ + type: 'file_repo' + + /** + * Unique identifier of the file branch. + */ + id: string + + /** + * Name of the file. + */ + name: string + + /** + * Key of the main file. + */ + main_file_key: string +} + +/** + * A project that a collection of Figma files are grouped under + */ +export type ActivityLogProjectEntity = { + /** + * The type of entity. + */ + type: 'project' + + /** + * Unique identifier of the project. + */ + id: string + + /** + * Name of the project. + */ + name: string +} + +/** + * A Figma team that contains multiple users and projects + */ +export type ActivityLogTeamEntity = { + /** + * The type of entity. + */ + type: 'team' + + /** + * Unique identifier of the team. + */ + id: string + + /** + * Name of the team. + */ + name: string +} + +/** + * Part of the organizational hierarchy of managing files and users within Figma, only available on + * the Enterprise Plan + */ +export type ActivityLogWorkspaceEntity = { + /** + * The type of entity. + */ + type: 'workspace' + + /** + * Unique identifier of the workspace. + */ + id: string + + /** + * Name of the workspace. + */ + name: string +} + +/** + * A Figma organization + */ +export type ActivityLogOrgEntity = { + /** + * The type of entity. + */ + type: 'org' + + /** + * Unique identifier of the organization. + */ + id: string + + /** + * Name of the organization. + */ + name: string +} + +/** + * A Figma plugin + */ +export type ActivityLogPluginEntity = { + /** + * The type of entity. + */ + type: 'plugin' + + /** + * Unique identifier of the plugin. + */ + id: string + + /** + * Name of the plugin. + */ + name: string + + /** + * Indicates if the object is a plugin is available on Figma Design or FigJam. + */ + editor_type: 'figma' | 'figjam' +} + +/** + * A Figma widget + */ +export type ActivityLogWidgetEntity = { + /** + * The type of entity. + */ + type: 'widget' + + /** + * Unique identifier of the widget. + */ + id: string + + /** + * Name of the widget. + */ + name: string + + /** + * Indicates if the object is a widget available on Figma Design or FigJam. + */ + editor_type: 'figma' | 'figjam' +} + +/** + * An event returned by the Activity Logs API. + */ +export type ActivityLog = { + /** + * The ID of the event. + */ + id: string + + /** + * The timestamp of the event in seconds since the Unix epoch. + */ + timestamp: number + + /** + * The user who performed the action. + */ + actor: object | null + + /** + * The task or activity the actor performed. + */ + action: { + /** + * The type of the action. + */ + type: string + + /** + * Metadata of the action. Each action type supports its own metadata attributes. + */ + details: object | null + } + + /** + * The resource the actor took the action on. It can be a user, file, project or other resource + * types. + */ + entity: + | ActivityLogUserEntity + | ActivityLogFileEntity + | ActivityLogFileRepoEntity + | ActivityLogProjectEntity + | ActivityLogTeamEntity + | ActivityLogWorkspaceEntity + | ActivityLogOrgEntity + | ActivityLogPluginEntity + | ActivityLogWidgetEntity + + /** + * Contextual information about the event. + */ + context: { + /** + * The third-party application that triggered the event, if applicable. + */ + client_name: string | null + + /** + * The IP address from of the client that sent the event request. + */ + ip_address: string + + /** + * If Figma's Support team triggered the event. This is either true or false. + */ + is_figma_support_team_action: boolean + + /** + * The id of the organization where the event took place. + */ + org_id: string + + /** + * The id of the team where the event took place -- if this took place in a specific team. + */ + team_id: string | null + } +} + +/** + * An object describing the user's payment status. + */ +export type PaymentStatus = { + /** + * The current payment status of the user on the resource, as a string enum: + * + * - `UNPAID`: user has not paid for the resource + * - `PAID`: user has an active purchase on the resource + * - `TRIAL`: user is in the trial period for a subscription resource + */ + type?: 'UNPAID' | 'PAID' | 'TRIAL' +} + +/** + * An object describing a user's payment information for a plugin, widget, or Community file. + */ +export type PaymentInformation = { + /** + * The ID of the user whose payment information was queried. Can be used to verify the validity of a + * response. + */ + user_id: string + + /** + * The ID of the plugin, widget, or Community file that was queried. Can be used to verify the + * validity of a response. + */ + resource_id: string + + /** + * The type of the resource. + */ + resource_type: 'PLUGIN' | 'WIDGET' | 'COMMUNITY_FILE' + + payment_status: PaymentStatus + + /** + * The UTC ISO 8601 timestamp indicating when the user purchased the resource. No value is given if + * the user has never purchased the resource. + * + * Note that a value will still be returned if the user had purchased the resource, but no longer + * has active access to it (e.g. purchase refunded, subscription ended). + */ + date_of_purchase?: string +} + +/** + * Scopes allow a variable to be shown or hidden in the variable picker for various fields. This + * declutters the Figma UI if you have a large number of variables. Variable scopes are currently + * supported on `FLOAT`, `STRING`, and `COLOR` variables. + * + * `ALL_SCOPES` is a special scope that means that the variable will be shown in the variable picker + * for all variable fields. If `ALL_SCOPES` is set, no additional scopes can be set. + * + * `ALL_FILLS` is a special scope that means that the variable will be shown in the variable picker + * for all fill fields. If `ALL_FILLS` is set, no additional fill scopes can be set. + * + * Valid scopes for `FLOAT` variables: + * + * - `ALL_SCOPES` + * - `TEXT_CONTENT` + * - `WIDTH_HEIGHT` + * - `GAP` + * - `STROKE_FLOAT` + * - `EFFECT_FLOAT` + * - `OPACITY` + * - `FONT_WEIGHT` + * - `FONT_SIZE` + * - `LINE_HEIGHT` + * - `LETTER_SPACING` + * - `PARAGRAPH_SPACING` + * - `PARAGRAPH_INDENT` + * + * Valid scopes for `STRING` variables: + * + * - `ALL_SCOPES` + * - `TEXT_CONTENT` + * - `FONT_FAMILY` + * - `FONT_STYLE` + * + * Valid scopes for `COLOR` variables: + * + * - `ALL_SCOPES` + * - `ALL_FILLS` + * - `FRAME_FILL` + * - `SHAPE_FILL` + * - `TEXT_FILL` + * - `STROKE_COLOR` + * - `EFFECT_COLOR` + */ +export type VariableScope = + | 'ALL_SCOPES' + | 'TEXT_CONTENT' + | 'CORNER_RADIUS' + | 'WIDTH_HEIGHT' + | 'GAP' + | 'ALL_FILLS' + | 'FRAME_FILL' + | 'SHAPE_FILL' + | 'TEXT_FILL' + | 'STROKE_COLOR' + | 'STROKE_FLOAT' + | 'EFFECT_FLOAT' + | 'EFFECT_COLOR' + | 'OPACITY' + | 'FONT_FAMILY' + | 'FONT_STYLE' + | 'FONT_WEIGHT' + | 'FONT_SIZE' + | 'LINE_HEIGHT' + | 'LETTER_SPACING' + | 'PARAGRAPH_SPACING' + | 'PARAGRAPH_INDENT' + | 'FONT_VARIATIONS' + +/** + * An object containing platform-specific code syntax definitions for a variable. All platforms are + * optional. + */ +export type VariableCodeSyntax = { + WEB?: string + + ANDROID?: string + + iOS?: string +} + +/** + * A grouping of related Variable objects each with the same modes. + */ +export type LocalVariableCollection = { + /** + * The unique identifier of this variable collection. + */ + id: string + + /** + * The name of this variable collection. + */ + name: string + + /** + * The key of this variable collection. + */ + key: string + + /** + * The modes of this variable collection. + */ + modes: { + /** + * The unique identifier of this mode. + */ + modeId: string + + /** + * The name of this mode. + */ + name: string + }[] + + /** + * The id of the default mode. + */ + defaultModeId: string + + /** + * Whether this variable collection is remote. + */ + remote: boolean + + /** + * Whether this variable collection is hidden when publishing the current file as a library. + */ + hiddenFromPublishing: boolean + + /** + * The ids of the variables in the collection. Note that the order of these variables is roughly the + * same as what is shown in Figma Design, however it does not account for groups. As a result, the + * order of these variables may not exactly reflect the exact ordering and grouping shown in the + * authoring UI. + */ + variableIds: string[] +} + +/** + * A Variable is a single design token that defines values for each of the modes in its + * VariableCollection. These values can be applied to various kinds of design properties. + */ +export type LocalVariable = { + /** + * The unique identifier of this variable. + */ + id: string + + /** + * The name of this variable. + */ + name: string + + /** + * The key of this variable. + */ + key: string + + /** + * The id of the variable collection that contains this variable. + */ + variableCollectionId: string + + /** + * The resolved type of the variable. + */ + resolvedType: 'BOOLEAN' | 'FLOAT' | 'STRING' | 'COLOR' + + /** + * The values for each mode of this variable. + */ + valuesByMode: { [key: string]: boolean | number | string | RGBA | VariableAlias } + + /** + * Whether this variable is remote. + */ + remote: boolean + + /** + * The description of this variable. + */ + description: string + + /** + * Whether this variable is hidden when publishing the current file as a library. + * + * If the parent `VariableCollection` is marked as `hiddenFromPublishing`, then this variable will + * also be hidden from publishing via the UI. `hiddenFromPublishing` is independently toggled for a + * variable and collection. However, both must be true for a given variable to be publishable. + */ + hiddenFromPublishing: boolean + + /** + * An array of scopes in the UI where this variable is shown. Setting this property will show/hide + * this variable in the variable picker UI for different fields. + * + * Setting scopes for a variable does not prevent that variable from being bound in other scopes + * (for example, via the Plugin API). This only limits the variables that are shown in pickers + * within the Figma UI. + */ + scopes: VariableScope[] + + codeSyntax: VariableCodeSyntax + + /** + * Indicates that the variable was deleted in the editor, but the document may still contain + * references to the variable. References to the variable may exist through bound values or variable + * aliases. + */ + deletedButReferenced?: boolean +} + +/** + * A grouping of related Variable objects each with the same modes. + */ +export type PublishedVariableCollection = { + /** + * The unique identifier of this variable collection. + */ + id: string + + /** + * The ID of the variable collection that is used by subscribing files. This ID changes every time + * the variable collection is modified and published. + */ + subscribed_id: string + + /** + * The name of this variable collection. + */ + name: string + + /** + * The key of this variable collection. + */ + key: string + + /** + * The UTC ISO 8601 time at which the variable collection was last updated. + * + * This timestamp will change any time a variable in the collection is changed. + */ + updatedAt: string +} + +/** + * A Variable is a single design token that defines values for each of the modes in its + * VariableCollection. These values can be applied to various kinds of design properties. + */ +export type PublishedVariable = { + /** + * The unique identifier of this variable. + */ + id: string + + /** + * The ID of the variable that is used by subscribing files. This ID changes every time the variable + * is modified and published. + */ + subscribed_id: string + + /** + * The name of this variable. + */ + name: string + + /** + * The key of this variable. + */ + key: string + + /** + * The id of the variable collection that contains this variable. + */ + variableCollectionId: string + + /** + * The resolved type of the variable. + */ + resolvedDataType: 'BOOLEAN' | 'FLOAT' | 'STRING' | 'COLOR' + + /** + * The UTC ISO 8601 time at which the variable was last updated. + */ + updatedAt: string +} + +/** + * An object that contains details about creating a `VariableCollection`. + */ +export type VariableCollectionCreate = { + /** + * The action to perform for the variable collection. + */ + action: 'CREATE' + + /** + * A temporary id for this variable collection. + */ + id?: string + + /** + * The name of this variable collection. + */ + name: string + + /** + * The initial mode refers to the mode that is created by default. You can set a temporary id here, + * in order to reference this mode later in this request. + */ + initialModeId?: string + + /** + * Whether this variable collection is hidden when publishing the current file as a library. + */ + hiddenFromPublishing?: boolean +} + +/** + * An object that contains details about updating a `VariableCollection`. + */ +export type VariableCollectionUpdate = { + /** + * The action to perform for the variable collection. + */ + action: 'UPDATE' + + /** + * The id of the variable collection to update. + */ + id: string + + /** + * The name of this variable collection. + */ + name?: string + + /** + * Whether this variable collection is hidden when publishing the current file as a library. + */ + hiddenFromPublishing?: boolean +} + +/** + * An object that contains details about deleting a `VariableCollection`. + */ +export type VariableCollectionDelete = { + /** + * The action to perform for the variable collection. + */ + action: 'DELETE' + + /** + * The id of the variable collection to delete. + */ + id: string +} + +export type VariableCollectionChange = + | VariableCollectionCreate + | VariableCollectionUpdate + | VariableCollectionDelete + +/** + * An object that contains details about creating a `VariableMode`. + */ +export type VariableModeCreate = { + /** + * The action to perform for the variable mode. + */ + action: 'CREATE' + + /** + * A temporary id for this variable mode. + */ + id?: string + + /** + * The name of this variable mode. + */ + name: string + + /** + * The variable collection that will contain the mode. You can use the temporary id of a variable + * collection. + */ + variableCollectionId: string +} + +/** + * An object that contains details about updating a `VariableMode`. + */ +export type VariableModeUpdate = { + /** + * The action to perform for the variable mode. + */ + action: 'UPDATE' + + /** + * The id of the variable mode to update. + */ + id: string + + /** + * The name of this variable mode. + */ + name?: string + + /** + * The variable collection that contains the mode. + */ + variableCollectionId: string +} + +/** + * An object that contains details about deleting a `VariableMode`. + */ +export type VariableModeDelete = { + /** + * The action to perform for the variable mode. + */ + action: 'DELETE' + + /** + * The id of the variable mode to delete. + */ + id: string +} + +export type VariableModeChange = VariableModeCreate | VariableModeUpdate | VariableModeDelete + +/** + * An object that contains details about creating a `Variable`. + */ +export type VariableCreate = { + /** + * The action to perform for the variable. + */ + action: 'CREATE' + + /** + * A temporary id for this variable. + */ + id?: string + + /** + * The name of this variable. + */ + name: string + + /** + * The variable collection that will contain the variable. You can use the temporary id of a + * variable collection. + */ + variableCollectionId: string + + /** + * The resolved type of the variable. + */ + resolvedType: 'BOOLEAN' | 'FLOAT' | 'STRING' | 'COLOR' + + /** + * The description of this variable. + */ + description?: string + + /** + * Whether this variable is hidden when publishing the current file as a library. + */ + hiddenFromPublishing?: boolean + + /** + * An array of scopes in the UI where this variable is shown. Setting this property will show/hide + * this variable in the variable picker UI for different fields. + */ + scopes?: VariableScope[] + + codeSyntax?: VariableCodeSyntax +} + +/** + * An object that contains details about updating a `Variable`. + */ +export type VariableUpdate = { + /** + * The action to perform for the variable. + */ + action: 'UPDATE' + + /** + * The id of the variable to update. + */ + id: string + + /** + * The name of this variable. + */ + name?: string + + /** + * The description of this variable. + */ + description?: string + + /** + * Whether this variable is hidden when publishing the current file as a library. + */ + hiddenFromPublishing?: boolean + + /** + * An array of scopes in the UI where this variable is shown. Setting this property will show/hide + * this variable in the variable picker UI for different fields. + */ + scopes?: VariableScope[] + + codeSyntax?: VariableCodeSyntax +} + +/** + * An object that contains details about deleting a `Variable`. + */ +export type VariableDelete = { + /** + * The action to perform for the variable. + */ + action: 'DELETE' + + /** + * The id of the variable to delete. + */ + id: string +} + +export type VariableChange = VariableCreate | VariableUpdate | VariableDelete + +/** + * An object that represents a value for a given mode of a variable. All properties are required. + */ +export type VariableModeValue = { + /** + * The target variable. You can use the temporary id of a variable. + */ + variableId: string + + /** + * Must correspond to a mode in the variable collection that contains the target variable. + */ + modeId: string + + value: VariableValue +} + +/** + * The value for the variable. The value must match the variable's type. If setting to a variable + * alias, the alias must resolve to this type. + */ +export type VariableValue = boolean | number | string | RGB | RGBA | VariableAlias + +/** + * A dev resource in a file + */ +export type DevResource = { + /** + * Unique identifier of the dev resource + */ + id: string + + /** + * The name of the dev resource. + */ + name: string + + /** + * The URL of the dev resource. + */ + url: string + + /** + * The file key where the dev resource belongs. + */ + file_key: string + + /** + * The target node to attach the dev resource to. + */ + node_id: string +} + +/** + * Library analytics component actions data broken down by asset. + */ +export type LibraryAnalyticsComponentActionsByAsset = { + /** + * The date in ISO 8601 format. e.g. 2023-12-13 + */ + week: string + + /** + * Unique, stable id of the component. + */ + component_key: string + + /** + * Name of the component. + */ + component_name: string + + /** + * Unique, stable id of the component set that this component belongs to. + */ + component_set_key?: string + + /** + * Name of the component set that this component belongs to. + */ + component_set_name?: string + + /** + * The number of detach events for this period. + */ + detachments: number + + /** + * The number of insertion events for this period. + */ + insertions: number +} + +/** + * Library analytics action data broken down by team. + */ +export type LibraryAnalyticsComponentActionsByTeam = { + /** + * The date in ISO 8601 format. e.g. 2023-12-13 + */ + week: string + + /** + * The name of the team using the library. + */ + team_name: string + + /** + * The name of the workspace that the team belongs to. + */ + workspace_name?: string + + /** + * The number of detach events for this period. + */ + detachments: number + + /** + * The number of insertion events for this period. + */ + insertions: number +} + +/** + * Library analytics component usage data broken down by component. + */ +export type LibraryAnalyticsComponentUsagesByAsset = { + /** + * Unique, stable id of the component. + */ + component_key: string + + /** + * Name of the component. + */ + component_name: string + + /** + * Unique, stable id of the component set that this component belongs to. + */ + component_set_key?: string + + /** + * Name of the component set that this component belongs to. + */ + component_set_name?: string + + /** + * The number of instances of the component within the organization. + */ + usages: number + + /** + * The number of teams using the component within the organization. + */ + teams_using: number + + /** + * The number of files using the component within the organization. + */ + files_using: number +} + +/** + * Library analytics component usage data broken down by file. + */ +export type LibraryAnalyticsComponentUsagesByFile = { + /** + * The name of the file using the library. + */ + file_name: string + + /** + * The name of the team the file belongs to. + */ + team_name: string + + /** + * The name of the workspace that the file belongs to. + */ + workspace_name?: string + + /** + * The number of component instances from the library used within the file. + */ + usages: number +} + +/** + * Library analytics style actions data broken down by asset. + */ +export type LibraryAnalyticsStyleActionsByAsset = { + /** + * The date in ISO 8601 format. e.g. 2023-12-13 + */ + week: string + + /** + * Unique, stable id of the style. + */ + style_key: string + + /** + * The name of the style. + */ + style_name: string + + /** + * The type of the style. + */ + style_type: string + + /** + * The number of detach events for this period. + */ + detachments: number + + /** + * The number of insertion events for this period. + */ + insertions: number +} + +/** + * Library analytics style action data broken down by team. + */ +export type LibraryAnalyticsStyleActionsByTeam = { + /** + * The date in ISO 8601 format. e.g. 2023-12-13 + */ + week: string + + /** + * The name of the team using the library. + */ + team_name: string + + /** + * The name of the workspace that the team belongs to. + */ + workspace_name?: string + + /** + * The number of detach events for this period. + */ + detachments: number + + /** + * The number of insertion events for this period. + */ + insertions: number +} + +/** + * Library analytics style usage data broken down by component. + */ +export type LibraryAnalyticsStyleUsagesByAsset = { + /** + * Unique, stable id of the style. + */ + style_key: string + + /** + * The name of the style. + */ + style_name: string + + /** + * The type of the style. + */ + style_type: string + + /** + * The number of usages of the style within the organization. + */ + usages: number + + /** + * The number of teams using the style within the organization. + */ + teams_using: number + + /** + * The number of files using the style within the organization. + */ + files_using: number +} + +/** + * Library analytics style usage data broken down by file. + */ +export type LibraryAnalyticsStyleUsagesByFile = { + /** + * The name of the file using the library. + */ + file_name: string + + /** + * The name of the team the file belongs to. + */ + team_name: string + + /** + * The name of the workspace that the file belongs to. + */ + workspace_name?: string + + /** + * The number of times styles from this library are used within the file. + */ + usages: number +} + +/** + * Library analytics variable actions data broken down by asset. + */ +export type LibraryAnalyticsVariableActionsByAsset = { + /** + * The date in ISO 8601 format. e.g. 2023-12-13 + */ + week: string + + /** + * Unique, stable id of the variable. + */ + variable_key: string + + /** + * The name of the variable. + */ + variable_name: string + + /** + * The type of the variable. + */ + variable_type: string + + /** + * Unique, stable id of the collection the variable belongs to. + */ + collection_key: string + + /** + * The name of the collection the variable belongs to. + */ + collection_name: string + + /** + * The number of detach events for this period. + */ + detachments: number + + /** + * The number of insertion events for this period. + */ + insertions: number +} + +/** + * Library analytics variable action data broken down by team. + */ +export type LibraryAnalyticsVariableActionsByTeam = { + /** + * The date in ISO 8601 format. e.g. 2023-12-13 + */ + week: string + + /** + * The name of the team using the library. + */ + team_name: string + + /** + * The name of the workspace that the team belongs to. + */ + workspace_name?: string + + /** + * The number of detach events for this period. + */ + detachments: number + + /** + * The number of insertion events for this period. + */ + insertions: number +} + +/** + * Library analytics variable usage data broken down by component. + */ +export type LibraryAnalyticsVariableUsagesByAsset = { + /** + * Unique, stable id of the variable. + */ + variable_key: string + + /** + * The name of the variable. + */ + variable_name: string + + /** + * The type of the variable. + */ + variable_type: string + + /** + * Unique, stable id of the collection the variable belongs to. + */ + collection_key: string + + /** + * The name of the collection the variable belongs to. + */ + collection_name: string + + /** + * The number of usages of the variable within the organization. + */ + usages: number + + /** + * The number of teams using the variable within the organization. + */ + teams_using: number + + /** + * The number of files using the variable within the organization. + */ + files_using: number +} + +/** + * Library analytics variable usage data broken down by file. + */ +export type LibraryAnalyticsVariableUsagesByFile = { + /** + * The name of the file using the library. + */ + file_name: string + + /** + * The name of the team the file belongs to. + */ + team_name: string + + /** + * The name of the workspace that the file belongs to. + */ + workspace_name?: string + + /** + * The number of times variables from this library are used within the file. + */ + usages: number +} + +/** + * If pagination is needed due to the length of the response, identifies the next and previous + * pages. + */ +export type ResponsePagination = { + /** + * A URL that calls the previous page of the response. + */ + prev_page?: string + + /** + * A URL that calls the next page of the response. + */ + next_page?: string +} + +/** + * Pagination cursor + */ +export type ResponseCursor = { + before?: number + + after?: number +} + +/** + * A response indicating an error occurred. + */ +export type ErrorResponsePayloadWithErrMessage = { + /** + * Status code + */ + status: number + + /** + * A string describing the error + */ + err: string +} + +/** + * A response indicating an error occurred. + */ +export type ErrorResponsePayloadWithErrorBoolean = { + /** + * For erroneous requests, this value is always `true`. + */ + error: true + + /** + * Status code + */ + status: number + + /** + * A string describing the error + */ + message: string +} + +/** + * Response from the GET /v1/files/{file_key} endpoint. + */ +export type GetFileResponse = { + /** + * The name of the file as it appears in the editor. + */ + name: string + + /** + * The role of the user making the API request in relation to the file. + */ + role: 'owner' | 'editor' | 'viewer' + + /** + * The UTC ISO 8601 time at which the file was last modified. + */ + lastModified: string + + /** + * The type of editor associated with this file. + */ + editorType: 'figma' | 'figjam' + + /** + * A URL to a thumbnail image of the file. + */ + thumbnailUrl?: string + + /** + * The version number of the file. This number is incremented when a file is modified and can be + * used to check if the file has changed between requests. + */ + version: string + + document: DocumentNode + + /** + * A mapping from component IDs to component metadata. + */ + components: { [key: string]: Component } + + /** + * A mapping from component set IDs to component set metadata. + */ + componentSets: { [key: string]: ComponentSet } + + /** + * The version of the file schema that this file uses. + */ + schemaVersion: number + + /** + * A mapping from style IDs to style metadata. + */ + styles: { [key: string]: Style } + + /** + * The share permission level of the file link. + */ + linkAccess?: string + + /** + * The key of the main file for this file. If present, this file is a component or component set. + */ + mainFileKey?: string + + /** + * A list of branches for this file. + */ + branches?: { + /** + * The key of the branch. + */ + key: string + + /** + * The name of the branch. + */ + name: string + + /** + * A URL to a thumbnail image of the branch. + */ + thumbnail_url: string + + /** + * The UTC ISO 8601 time at which the branch was last modified. + */ + last_modified: string + }[] +} + +/** + * Response from the GET /v1/files/{file_key}/nodes endpoint. + */ +export type GetFileNodesResponse = { + /** + * The name of the file as it appears in the editor. + */ + name: string + + /** + * The role of the user making the API request in relation to the file. + */ + role: 'owner' | 'editor' | 'viewer' + + /** + * The UTC ISO 8601 time at which the file was last modified. + */ + lastModified: string + + /** + * The type of editor associated with this file. + */ + editorType: 'figma' | 'figjam' + + /** + * A URL to a thumbnail image of the file. + */ + thumbnailUrl: string + + /** + * The version number of the file. This number is incremented when a file is modified and can be + * used to check if the file has changed between requests. + */ + version: string + + /** + * A mapping from node IDs to node metadata. + */ + nodes: { + [key: string]: { + document: Node + + /** + * A mapping from component IDs to component metadata. + */ + components: { [key: string]: Component } + + /** + * A mapping from component set IDs to component set metadata. + */ + componentSets: { [key: string]: ComponentSet } + + /** + * The version of the file schema that this file uses. + */ + schemaVersion: number + + /** + * A mapping from style IDs to style metadata. + */ + styles: { [key: string]: Style } + } + } +} + +/** + * Response from the GET /v1/images/{file_key} endpoint. + */ +export type GetImagesResponse = { + /** + * For successful requests, this value is always `null`. + */ + err: null + + /** + * A map from node IDs to URLs of the rendered images. + */ + images: { [key: string]: string | null } +} + +/** + * Response from the GET /v1/files/{file_key}/images endpoint. + */ +export type GetImageFillsResponse = { + /** + * For successful requests, this value is always `false`. + */ + error: false + + /** + * Status code + */ + status: 200 + + meta: { + /** + * A map of image references to URLs of the image fills. + */ + images: { [key: string]: string } + } +} + +/** + * Response from the GET /v1/files/{file_key}/meta endpoint. + */ +export type GetFileMetaResponse = { + /** + * The name of the file. + */ + name: string + + /** + * The name of the project containing the file. + */ + folder_name?: string + + /** + * The UTC ISO 8601 time at which the file content was last modified. + */ + last_touched_at: string + + /** + * The user who created the file. + */ + creator: User + + /** + * The user who last modified the file contents. + */ + last_touched_by?: User + + /** + * A URL to a thumbnail image of the file. + */ + thumbnail_url?: string + + /** + * The type of editor associated with this file. + */ + editorType: 'figma' | 'figjam' | 'slides' + + /** + * The role of the user making the API request in relation to the file. + */ + role?: 'owner' | 'editor' | 'viewer' + + /** + * Access policy for users who have the link to the file. + */ + link_access?: 'view' | 'edit' | 'org_view' | 'org_edit' | 'inherit' + + /** + * The URL of the file. + */ + url?: string + + /** + * The version number of the file. This number is incremented when a file is modified and can be + * used to check if the file has changed between requests. + */ + version?: string +} + +/** + * Response from the GET /v1/teams/{team_id}/projects endpoint. + */ +export type GetTeamProjectsResponse = { + /** + * The team's name. + */ + name: string + + /** + * An array of projects. + */ + projects: Project[] +} + +/** + * Response from the GET /v1/projects/{project_id}/files endpoint. + */ +export type GetProjectFilesResponse = { + /** + * The project's name. + */ + name: string + + /** + * An array of files. + */ + files: { + /** + * The file's key. + */ + key: string + + /** + * The file's name. + */ + name: string + + /** + * The file's thumbnail URL. + */ + thumbnail_url?: string + + /** + * The UTC ISO 8601 time at which the file was last modified. + */ + last_modified: string + }[] +} + +/** + * Response from the GET /v1/files/{file_key}/versions endpoint. + */ +export type GetFileVersionsResponse = { + /** + * An array of versions. + */ + versions: Version[] + + pagination: ResponsePagination +} + +/** + * Response from the GET /v1/files/{file_key}/comments endpoint. + */ +export type GetCommentsResponse = { + /** + * An array of comments. + */ + comments: Comment[] +} + +/** + * Response from the POST /v1/files/{file_key}/comments endpoint. + */ +export type PostCommentResponse = Comment + +/** + * Response from the DELETE /v1/files/{file_key}/comments/{comment_id} endpoint. + */ +export type DeleteCommentResponse = { + /** + * The status of the request. + */ + status: 200 + + /** + * For successful requests, this value is always `false`. + */ + error: false +} + +/** + * Response from the GET /v1/files/{file_key}/comments/{comment_id}/reactions endpoint. + */ +export type GetCommentReactionsResponse = { + /** + * An array of reactions. + */ + reactions: Reaction[] + + pagination: ResponsePagination +} + +/** + * Response from the POST /v1/files/{file_key}/comments/{comment_id}/reactions endpoint. + */ +export type PostCommentReactionResponse = { + /** + * The status of the request. + */ + status: 200 + + /** + * For successful requests, this value is always `false`. + */ + error: false +} + +/** + * Response from the DELETE /v1/files/{file_key}/comments/{comment_id}/reactions endpoint. + */ +export type DeleteCommentReactionResponse = { + /** + * The status of the request. + */ + status: 200 + + /** + * For successful requests, this value is always `false`. + */ + error: false +} + +/** + * Response from the GET /v1/me endpoint. + */ +export type GetMeResponse = User & { + /** + * Email associated with the user's account. This property is only present on the /v1/me endpoint. + */ + email: string +} + +/** + * Response from the GET /v1/teams/{team_id}/components endpoint. + */ +export type GetTeamComponentsResponse = { + /** + * The status of the request. + */ + status: 200 + + /** + * For successful requests, this value is always `false`. + */ + error: false + + meta: { + components: PublishedComponent[] + + cursor?: ResponseCursor + } +} + +/** + * Response from the GET /v1/files/{file_key}/components endpoint. + */ +export type GetFileComponentsResponse = { + /** + * The status of the request. + */ + status: 200 + + /** + * For successful requests, this value is always `false`. + */ + error: false + + meta: { components: PublishedComponent[] } +} + +/** + * Response from the GET /v1/components/{key} endpoint. + */ +export type GetComponentResponse = { + /** + * The status of the request. + */ + status: 200 + + /** + * For successful requests, this value is always `false`. + */ + error: false + + meta: PublishedComponent +} + +/** + * Response from the GET /v1/teams/{team_id}/component_sets endpoint. + */ +export type GetTeamComponentSetsResponse = { + /** + * The status of the request. + */ + status: 200 + + /** + * For successful requests, this value is always `false`. + */ + error: false + + meta: { + component_sets: PublishedComponentSet[] + + cursor?: ResponseCursor + } +} + +/** + * Response from the GET /v1/files/{file_key}/component_sets endpoint. + */ +export type GetFileComponentSetsResponse = { + /** + * The status of the request. + */ + status: 200 + + /** + * For successful requests, this value is always `false`. + */ + error: false + + meta: { component_sets: PublishedComponentSet[] } +} + +/** + * Response from the GET /v1/component_sets/{key} endpoint. + */ +export type GetComponentSetResponse = { + /** + * The status of the request. + */ + status: 200 + + /** + * For successful requests, this value is always `false`. + */ + error: false + + meta: PublishedComponentSet +} + +/** + * Response from the GET /v1/teams/{team_id}/styles endpoint. + */ +export type GetTeamStylesResponse = { + /** + * The status of the request. + */ + status: 200 + + /** + * For successful requests, this value is always `false`. + */ + error: false + + meta: { + styles: PublishedStyle[] + + cursor?: ResponseCursor + } +} + +/** + * Response from the GET /v1/files/{file_key}/styles endpoint. + */ +export type GetFileStylesResponse = { + /** + * The status of the request. + */ + status: 200 + + /** + * For successful requests, this value is always `false`. + */ + error: false + + meta: { styles: PublishedStyle[] } +} + +/** + * Response from the GET /v1/styles/{key} endpoint. + */ +export type GetStyleResponse = { + /** + * The status of the request. + */ + status: 200 + + /** + * For successful requests, this value is always `false`. + */ + error: false + + meta: PublishedStyle +} + +/** + * Response from the POST /v2/webhooks endpoint. + */ +export type PostWebhookResponse = WebhookV2 + +/** + * Response from the GET /v2/webhooks/{webhook_id} endpoint. + */ +export type GetWebhookResponse = WebhookV2 + +/** + * Response from the GET /v2/webhooks endpoint. + */ +export type GetWebhooksResponse = { + /** + * An array of webhooks. + */ + webhooks: WebhookV2[] + + pagination?: ResponsePagination +} + +/** + * Response from the PUT /v2/webhooks/{webhook_id} endpoint. + */ +export type PutWebhookResponse = WebhookV2 + +/** + * Response from the DELETE /v2/webhooks/{webhook_id} endpoint. + */ +export type DeleteWebhookResponse = WebhookV2 + +/** + * Response from the GET /v2/teams/{team_id}/webhooks endpoint. + */ +export type GetTeamWebhooksResponse = { + /** + * An array of webhooks. + */ + webhooks: WebhookV2[] +} + +/** + * Response from the GET /v2/webhooks/{webhook_id}/requests endpoint. + */ +export type GetWebhookRequestsResponse = { + /** + * An array of webhook requests. + */ + requests: WebhookV2Request[] +} + +/** + * Response from the GET /v1/activity_logs endpoint. + */ +export type GetActivityLogsResponse = { + /** + * The response status code. + */ + status?: 200 + + /** + * For successful requests, this value is always `false`. + */ + error?: false + + meta?: { + /** + * An array of activity logs sorted by timestamp in ascending order by default. + */ + activity_logs?: ActivityLog[] + + /** + * Encodes the last event (the most recent event) + */ + cursor?: string + + /** + * Whether there is a next page of events + */ + next_page?: boolean + } +} + +/** + * Response from the GET /v1/payments endpoint. + */ +export type GetPaymentsResponse = { + /** + * The response status code. + */ + status: 200 + + /** + * For successful requests, this value is always `false`. + */ + error: false + + meta: PaymentInformation +} + +/** + * Response from the GET /v1/files/{file_key}/variables/local endpoint. + */ +export type GetLocalVariablesResponse = { + /** + * The response status code. + */ + status: 200 + + /** + * For successful requests, this value is always `false`. + */ + error: false + + meta: { + /** + * A map of variable ids to variables + */ + variables: { [key: string]: LocalVariable } + + /** + * A map of variable collection ids to variable collections + */ + variableCollections: { [key: string]: LocalVariableCollection } + } +} + +/** + * Response from the GET /v1/files/{file_key}/variables/published endpoint. + */ +export type GetPublishedVariablesResponse = { + /** + * The response status code. + */ + status: 200 + + /** + * For successful requests, this value is always `false`. + */ + error: false + + meta: { + /** + * A map of variable ids to variables + */ + variables: { [key: string]: PublishedVariable } + + /** + * A map of variable collection ids to variable collections + */ + variableCollections: { [key: string]: PublishedVariableCollection } + } +} + +/** + * Response from the POST /v1/files/{file_key}/variables endpoint. + */ +export type PostVariablesResponse = { + /** + * The response status code. + */ + status: 200 + + /** + * For successful requests, this value is always `false`. + */ + error: false + + meta: { + /** + * A map of temporary ids in the request to the real ids of the newly created objects + */ + tempIdToRealId: { [key: string]: string } + } +} + +/** + * Response from the GET /v1/files/{file_key}/dev_resources endpoint. + */ +export type GetDevResourcesResponse = { + /** + * An array of dev resources. + */ + dev_resources: DevResource[] +} + +/** + * Response from the POST /v1/dev_resources endpoint. + */ +export type PostDevResourcesResponse = { + /** + * An array of links created. + */ + links_created: DevResource[] + + /** + * An array of errors. + */ + errors?: { + /** + * The file key. + */ + file_key?: string | null + + /** + * The node id. + */ + node_id?: string | null + + /** + * The error message. + */ + error: string + }[] +} + +/** + * Response from the PUT /v1/dev_resources endpoint. + */ +export type PutDevResourcesResponse = { + /** + * An array of links updated. + */ + links_updated?: DevResource[] + + /** + * An array of errors. + */ + errors?: { + /** + * The id of the dev resource. + */ + id?: string + + /** + * The error message. + */ + error: string + }[] +} + +/** + * Response from the DELETE /v1/files/{file_key}/dev_resources/{dev_resource_id} endpoint. + */ +export type DeleteDevResourceResponse = void + +/** + * Response from the GET /v1/analytics/libraries/{file_key}/component/actions. + */ +export type GetLibraryAnalyticsComponentActionsResponse = { + /** + * An array of analytics data. + */ + rows: LibraryAnalyticsComponentActionsByAsset[] | LibraryAnalyticsComponentActionsByTeam[] + + /** + * Whether there is a next page of data that can be fetched. + */ + next_page: boolean + + /** + * The cursor to use to fetch the next page of data. Not present if next_page is false. + */ + cursor?: string +} + +/** + * Response from the PUT /v1/analytics/libraries/{file_key}/component/usages. + */ +export type GetLibraryAnalyticsComponentUsagesResponse = { + /** + * An array of analytics data. + */ + rows: LibraryAnalyticsComponentUsagesByAsset[] | LibraryAnalyticsComponentUsagesByFile[] + + /** + * Whether there is a next page of data that can be fetched. + */ + next_page: boolean + + /** + * The cursor to use to fetch the next page of data. Not present if next_page is false. + */ + cursor?: string +} + +/** + * Response from the GET /v1/analytics/libraries/{file_key}/style/actions. + */ +export type GetLibraryAnalyticsStyleActionsResponse = { + /** + * An array of analytics data. + */ + rows: LibraryAnalyticsStyleActionsByAsset[] | LibraryAnalyticsStyleActionsByTeam[] + + /** + * Whether there is a next page of data that can be fetched. + */ + next_page: boolean + + /** + * The cursor to use to fetch the next page of data. Not present if next_page is false. + */ + cursor?: string +} + +/** + * Response from the PUT /v1/analytics/libraries/{file_key}/style/usages. + */ +export type GetLibraryAnalyticsStyleUsagesResponse = { + /** + * An array of analytics data. + */ + rows: LibraryAnalyticsStyleUsagesByAsset[] | LibraryAnalyticsStyleUsagesByFile[] + + /** + * Whether there is a next page of data that can be fetched. + */ + next_page: boolean + + /** + * The cursor to use to fetch the next page of data. Not present if next_page is false. + */ + cursor?: string +} + +/** + * Response from the GET /v1/analytics/libraries/{file_key}/variable/actions. + */ +export type GetLibraryAnalyticsVariableActionsResponse = { + /** + * An array of analytics data. + */ + rows: LibraryAnalyticsVariableActionsByAsset[] | LibraryAnalyticsVariableActionsByTeam[] + + /** + * Whether there is a next page of data that can be fetched. + */ + next_page: boolean + + /** + * The cursor to use to fetch the next page of data. Not present if next_page is false. + */ + cursor?: string +} + +/** + * Response from the PUT /v1/analytics/libraries/{file_key}/variable/usages. + */ +export type GetLibraryAnalyticsVariableUsagesResponse = { + /** + * An array of analytics data. + */ + rows: LibraryAnalyticsVariableUsagesByAsset[] | LibraryAnalyticsVariableUsagesByFile[] + + /** + * Whether there is a next page of data that can be fetched. + */ + next_page: boolean + + /** + * The cursor to use to fetch the next page of data. Not present if next_page is false. + */ + cursor?: string +} + +/** + * Bad request. Parameters are invalid or malformed. Please check the input formats. This error can + * also happen if the requested resources are too large to complete the request, which results in a + * timeout. Please reduce the number and size of objects requested. + */ +export type BadRequestErrorResponseWithErrMessage = ErrorResponsePayloadWithErrMessage & { + /** + * Status code + */ + status: 400 +} + +/** + * Bad request. Parameters are invalid or malformed. Please check the input formats. This error can + * also happen if the requested resources are too large to complete the request, which results in a + * timeout. Please reduce the number and size of objects requested. + */ +export type BadRequestErrorResponseWithErrorBoolean = ErrorResponsePayloadWithErrorBoolean & { + /** + * Status code + */ + status: 400 +} + +/** + * Token is missing or incorrect. + */ +export type UnauthorizedErrorResponseWithErrorBoolean = ErrorResponsePayloadWithErrorBoolean & { + /** + * Status code + */ + status: 401 +} + +/** + * The request was valid, but the server is refusing action. The user might not have the necessary + * permissions for a resource, or may need an account of some sort. + */ +export type ForbiddenErrorResponseWithErrMessage = ErrorResponsePayloadWithErrMessage & { + /** + * Status code + */ + status: 403 +} + +/** + * The request was valid, but the server is refusing action. The user might not have the necessary + * permissions for a resource, or may need an account of some sort. + */ +export type ForbiddenErrorResponseWithErrorBoolean = ErrorResponsePayloadWithErrorBoolean & { + /** + * Status code + */ + status: 403 +} + +/** + * The requested file or resource was not found. + */ +export type NotFoundErrorResponseWithErrMessage = ErrorResponsePayloadWithErrMessage & { + /** + * Status code + */ + status: 404 +} + +/** + * The requested file or resource was not found. + */ +export type NotFoundErrorResponseWithErrorBoolean = ErrorResponsePayloadWithErrorBoolean & { + /** + * Status code + */ + status: 404 +} + +/** + * In some cases API requests may be throttled or rate limited. Please wait a while before + * attempting the request again (typically a minute). + */ +export type TooManyRequestsErrorResponseWithErrMessage = ErrorResponsePayloadWithErrMessage & { + /** + * Status code + */ + status: 429 +} + +/** + * In some cases API requests may be throttled or rate limited. Please wait a while before + * attempting the request again (typically a minute). + */ +export type TooManyRequestsErrorResponseWithErrorBoolean = ErrorResponsePayloadWithErrorBoolean & { + /** + * Status code + */ + status: 429 +} + +/** + * An internal server error occurred. + */ +export type InternalServerErrorResponseWithErrMessage = ErrorResponsePayloadWithErrMessage & { + /** + * Status code + */ + status: 500 +} + +/** + * An internal server error occurred. + */ +export type InternalServerErrorResponseWithErrorBoolean = ErrorResponsePayloadWithErrorBoolean & { + /** + * Status code + */ + status: 500 +} + +/** + * Path parameters for GET /v1/files/{file_key} + */ +export type GetFilePathParams = { + /** + * File to export JSON from. This can be a file key or branch key. Use `GET /v1/files/:key` with + * the `branch_data` query param to get the branch key. + */ + file_key: string +} + +/** + * Query parameters for GET /v1/files/{file_key} + */ +export type GetFileQueryParams = { + /** + * A specific version ID to get. Omitting this will get the current version of the file. + */ + version?: string + /** + * Comma separated list of nodes that you care about in the document. If specified, only a subset of + * the document will be returned corresponding to the nodes listed, their children, and everything + * between the root node and the listed nodes. + * + * Note: There may be other nodes included in the returned JSON that are outside the ancestor chains + * of the desired nodes. The response may also include dependencies of anything in the nodes' + * subtrees. For example, if a node subtree contains an instance of a local component that lives + * elsewhere in that file, that component and its ancestor chain will also be included. + * + * For historical reasons, top-level canvas nodes are always returned, regardless of whether they + * are listed in the `ids` parameter. This quirk may be removed in a future version of the API. + */ + ids?: string + /** + * Positive integer representing how deep into the document tree to traverse. For example, setting + * this to 1 returns only Pages, setting it to 2 returns Pages and all top level objects on each + * page. Not setting this parameter returns all nodes. + */ + depth?: number + /** + * Set to "paths" to export vector data. + */ + geometry?: string + /** + * A comma separated list of plugin IDs and/or the string "shared". Any data present in the document + * written by those plugins will be included in the result in the `pluginData` and + * `sharedPluginData` properties. + */ + plugin_data?: string + /** + * Returns branch metadata for the requested file. If the file is a branch, the main file's key will + * be included in the returned response. If the file has branches, their metadata will be included + * in the returned response. Default: false. + */ + branch_data?: boolean +} + +/** + * Path parameters for GET /v1/files/{file_key}/nodes + */ +export type GetFileNodesPathParams = { + /** + * File to export JSON from. This can be a file key or branch key. Use `GET /v1/files/:key` with + * the `branch_data` query param to get the branch key. + */ + file_key: string +} + +/** + * Query parameters for GET /v1/files/{file_key}/nodes + */ +export type GetFileNodesQueryParams = { + /** + * A comma separated list of node IDs to retrieve and convert. + */ + ids: string + /** + * A specific version ID to get. Omitting this will get the current version of the file. + */ + version?: string + /** + * Positive integer representing how deep into the node tree to traverse. For example, setting this + * to 1 will return only the children directly underneath the desired nodes. Not setting this + * parameter returns all nodes. + * + * Note: this parameter behaves differently from the same parameter in the `GET /v1/files/:key` + * endpoint. In this endpoint, the depth will be counted starting from the desired node rather than + * the document root node. + */ + depth?: number + /** + * Set to "paths" to export vector data. + */ + geometry?: string + /** + * A comma separated list of plugin IDs and/or the string "shared". Any data present in the document + * written by those plugins will be included in the result in the `pluginData` and + * `sharedPluginData` properties. + */ + plugin_data?: string +} + +/** + * Path parameters for GET /v1/images/{file_key} + */ +export type GetImagesPathParams = { + /** + * File to export images from. This can be a file key or branch key. Use `GET /v1/files/:key` with + * the `branch_data` query param to get the branch key. + */ + file_key: string +} + +/** + * Query parameters for GET /v1/images/{file_key} + */ +export type GetImagesQueryParams = { + /** + * A comma separated list of node IDs to render. + */ + ids: string + /** + * A specific version ID to get. Omitting this will get the current version of the file. + */ + version?: string + /** + * A number between 0.01 and 4, the image scaling factor. + */ + scale?: number + /** + * A string enum for the image output format. + */ + format?: 'jpg' | 'png' | 'svg' | 'pdf' + /** + * Whether text elements are rendered as outlines (vector paths) or as `` elements in SVGs. + * + * Rendering text elements as outlines guarantees that the text looks exactly the same in the SVG as + * it does in the browser/inside Figma. + * + * Exporting as `` allows text to be selectable inside SVGs and generally makes the SVG easier + * to read. However, this relies on the browser's rendering engine which can vary between browsers + * and/or operating systems. As such, visual accuracy is not guaranteed as the result could look + * different than in Figma. + */ + svg_outline_text?: boolean + /** + * Whether to include id attributes for all SVG elements. Adds the layer name to the `id` attribute + * of an svg element. + */ + svg_include_id?: boolean + /** + * Whether to include node id attributes for all SVG elements. Adds the node id to a `data-node-id` + * attribute of an svg element. + */ + svg_include_node_id?: boolean + /** + * Whether to simplify inside/outside strokes and use stroke attribute if possible instead of + * ``. + */ + svg_simplify_stroke?: boolean + /** + * Whether content that overlaps the node should be excluded from rendering. Passing false (i.e., + * rendering overlaps) may increase processing time, since more of the document must be included in + * rendering. + */ + contents_only?: boolean + /** + * Use the full dimensions of the node regardless of whether or not it is cropped or the space + * around it is empty. Use this to export text nodes without cropping. + */ + use_absolute_bounds?: boolean +} + +/** + * Path parameters for GET /v1/files/{file_key}/images + */ +export type GetImageFillsPathParams = { + /** + * File to get image URLs from. This can be a file key or branch key. Use `GET /v1/files/:key` with + * the `branch_data` query param to get the branch key. + */ + file_key: string +} + +/** + * Path parameters for GET /v1/files/{file_key}/meta + */ +export type GetFileMetaPathParams = { + /** + * File to get metadata for. This can be a file key or branch key. Use `GET /v1/files/:key` with + * the `branch_data` query param to get the branch key. + */ + file_key: string +} + +/** + * Path parameters for GET /v1/teams/{team_id}/projects + */ +export type GetTeamProjectsPathParams = { + /** + * ID of the team to list projects from + */ + team_id: string +} + +/** + * Path parameters for GET /v1/projects/{project_id}/files + */ +export type GetProjectFilesPathParams = { + /** + * ID of the project to list files from + */ + project_id: string +} + +/** + * Query parameters for GET /v1/projects/{project_id}/files + */ +export type GetProjectFilesQueryParams = { + /** + * Returns branch metadata in the response for each main file with a branch inside the project. + */ + branch_data?: boolean +} + +/** + * Path parameters for GET /v1/files/{file_key}/versions + */ +export type GetFileVersionsPathParams = { + /** + * File to get version history from. This can be a file key or branch key. Use `GET /v1/files/:key` + * with the `branch_data` query param to get the branch key. + */ + file_key: string +} + +/** + * Query parameters for GET /v1/files/{file_key}/versions + */ +export type GetFileVersionsQueryParams = { + /** + * The number of items returned in a page of the response. If not included, `page_size` is `30`. + */ + page_size?: number + /** + * A version ID for one of the versions in the history. Gets versions before this ID. Used for + * paginating. If the response is not paginated, this link returns the same data in the current + * response. + */ + before?: number + /** + * A version ID for one of the versions in the history. Gets versions after this ID. Used for + * paginating. If the response is not paginated, this property is not included. + */ + after?: number +} + +/** + * Path parameters for GET /v1/files/{file_key}/comments + */ +export type GetCommentsPathParams = { + /** + * File to get comments from. This can be a file key or branch key. Use `GET /v1/files/:key` with + * the `branch_data` query param to get the branch key. + */ + file_key: string +} + +/** + * Query parameters for GET /v1/files/{file_key}/comments + */ +export type GetCommentsQueryParams = { + /** + * If enabled, will return comments as their markdown equivalents when applicable. + */ + as_md?: boolean +} + +/** + * Path parameters for POST /v1/files/{file_key}/comments + */ +export type PostCommentPathParams = { + /** + * File to add comments in. This can be a file key or branch key. Use `GET /v1/files/:key` with the + * `branch_data` query param to get the branch key. + */ + file_key: string +} + +/** + * Request body parameters for POST /v1/files/{file_key}/comments + */ +export type PostCommentRequestBody = { + /** + * The text contents of the comment to post. + */ + message: string + + /** + * The ID of the comment to reply to, if any. This must be a root comment. You cannot reply to other + * replies (a comment that has a parent_id). + */ + comment_id?: string + + /** + * The position where to place the comment. + */ + client_meta?: Vector | FrameOffset | Region | FrameOffsetRegion +} + +/** + * Path parameters for DELETE /v1/files/{file_key}/comments/{comment_id} + */ +export type DeleteCommentPathParams = { + /** + * File to delete comment from. This can be a file key or branch key. Use `GET /v1/files/:key` with + * the `branch_data` query param to get the branch key. + */ + file_key: string + /** + * Comment id of comment to delete + */ + comment_id: string +} + +/** + * Path parameters for DELETE /v1/files/{file_key}/comments/{comment_id}/reactions + */ +export type DeleteCommentReactionPathParams = { + /** + * File to delete comment reaction from. This can be a file key or branch key. Use `GET + * /v1/files/:key` with the `branch_data` query param to get the branch key. + */ + file_key: string + /** + * ID of comment to delete reaction from. + */ + comment_id: string +} + +/** + * Query parameters for DELETE /v1/files/{file_key}/comments/{comment_id}/reactions + */ +export type DeleteCommentReactionQueryParams = { emoji: Emoji } + +/** + * Path parameters for GET /v1/files/{file_key}/comments/{comment_id}/reactions + */ +export type GetCommentReactionsPathParams = { + /** + * File to get comment containing reactions from. This can be a file key or branch key. Use `GET + * /v1/files/:key` with the `branch_data` query param to get the branch key. + */ + file_key: string + /** + * ID of comment to get reactions from. + */ + comment_id: string +} + +/** + * Query parameters for GET /v1/files/{file_key}/comments/{comment_id}/reactions + */ +export type GetCommentReactionsQueryParams = { + /** + * Cursor for pagination, retrieved from the response of the previous call. + */ + cursor?: string +} + +/** + * Path parameters for POST /v1/files/{file_key}/comments/{comment_id}/reactions + */ +export type PostCommentReactionPathParams = { + /** + * File to post comment reactions to. This can be a file key or branch key. Use `GET + * /v1/files/:key` with the `branch_data` query param to get the branch key. + */ + file_key: string + /** + * ID of comment to react to. + */ + comment_id: string +} + +/** + * Request body parameters for POST /v1/files/{file_key}/comments/{comment_id}/reactions + */ +export type PostCommentReactionRequestBody = { emoji: Emoji } + +/** + * Path parameters for GET /v1/teams/{team_id}/components + */ +export type GetTeamComponentsPathParams = { + /** + * Id of the team to list components from. + */ + team_id: string +} + +/** + * Query parameters for GET /v1/teams/{team_id}/components + */ +export type GetTeamComponentsQueryParams = { + /** + * Number of items to return in a paged list of results. Defaults to 30. + */ + page_size?: number + /** + * Cursor indicating which id after which to start retrieving components for. Exclusive with before. + * The cursor value is an internally tracked integer that doesn't correspond to any Ids. + */ + after?: number + /** + * Cursor indicating which id before which to start retrieving components for. Exclusive with after. + * The cursor value is an internally tracked integer that doesn't correspond to any Ids. + */ + before?: number +} + +/** + * Path parameters for GET /v1/files/{file_key}/components + */ +export type GetFileComponentsPathParams = { + /** + * File to list components from. This must be a main file key, not a branch key, as it is not + * possible to publish from branches. + */ + file_key: string +} + +/** + * Path parameters for GET /v1/components/{key} + */ +export type GetComponentPathParams = { + /** + * The unique identifier of the component. + */ + key: string +} + +/** + * Path parameters for GET /v1/teams/{team_id}/component_sets + */ +export type GetTeamComponentSetsPathParams = { + /** + * Id of the team to list component sets from. + */ + team_id: string +} + +/** + * Query parameters for GET /v1/teams/{team_id}/component_sets + */ +export type GetTeamComponentSetsQueryParams = { + /** + * Number of items to return in a paged list of results. Defaults to 30. + */ + page_size?: number + /** + * Cursor indicating which id after which to start retrieving component sets for. Exclusive with + * before. The cursor value is an internally tracked integer that doesn't correspond to any Ids. + */ + after?: number + /** + * Cursor indicating which id before which to start retrieving component sets for. Exclusive with + * after. The cursor value is an internally tracked integer that doesn't correspond to any Ids. + */ + before?: number +} + +/** + * Path parameters for GET /v1/files/{file_key}/component_sets + */ +export type GetFileComponentSetsPathParams = { + /** + * File to list component sets from. This must be a main file key, not a branch key, as it is not + * possible to publish from branches. + */ + file_key: string +} + +/** + * Path parameters for GET /v1/component_sets/{key} + */ +export type GetComponentSetPathParams = { + /** + * The unique identifier of the component set. + */ + key: string +} + +/** + * Path parameters for GET /v1/teams/{team_id}/styles + */ +export type GetTeamStylesPathParams = { + /** + * Id of the team to list styles from. + */ + team_id: string +} + +/** + * Query parameters for GET /v1/teams/{team_id}/styles + */ +export type GetTeamStylesQueryParams = { + /** + * Number of items to return in a paged list of results. Defaults to 30. + */ + page_size?: number + /** + * Cursor indicating which id after which to start retrieving styles for. Exclusive with before. The + * cursor value is an internally tracked integer that doesn't correspond to any Ids. + */ + after?: number + /** + * Cursor indicating which id before which to start retrieving styles for. Exclusive with after. The + * cursor value is an internally tracked integer that doesn't correspond to any Ids. + */ + before?: number +} + +/** + * Path parameters for GET /v1/files/{file_key}/styles + */ +export type GetFileStylesPathParams = { + /** + * File to list styles from. This must be a main file key, not a branch key, as it is not possible + * to publish from branches. + */ + file_key: string +} + +/** + * Path parameters for GET /v1/styles/{key} + */ +export type GetStylePathParams = { + /** + * The unique identifier of the style. + */ + key: string +} + +/** + * Query parameters for GET /v2/webhooks + */ +export type GetWebhooksQueryParams = { + /** + * Context to create the resource on. Should be "team", "project", or "file". + */ + context?: string + /** + * The id of the context that you want to get attached webhooks for. If you're using context_id, you + * cannot use plan_api_id. + */ + context_id?: string + /** + * The id of your plan. Use this to get all webhooks for all contexts you have access to. If you're + * using plan_api_id, you cannot use context or context_id. When you use plan_api_id, the response + * is paginated. + */ + plan_api_id?: string + /** + * If you're using plan_api_id, this is the cursor to use for pagination. If you're using context or + * context_id, this parameter is ignored. Provide the next_page or prev_page value from the previous + * response to get the next or previous page of results. + */ + cursor?: string +} + +/** + * Request body parameters for POST /v2/webhooks + */ +export type PostWebhookRequestBody = { + event_type: WebhookV2Event + + /** + * Team id to receive updates about. This is deprecated, use 'context' and 'context_id' instead. + * + * @deprecated + */ + team_id?: string + + /** + * Context to create the webhook for. Must be "team", "project", or "file". + */ + context: string + + /** + * The id of the context you want to receive updates about. + */ + context_id: string + + /** + * The HTTP endpoint that will receive a POST request when the event triggers. Max length 2048 + * characters. + */ + endpoint: string + + /** + * String that will be passed back to your webhook endpoint to verify that it is being called by + * Figma. Max length 100 characters. + */ + passcode: string + + /** + * State of the webhook, including any error state it may be in + */ + status?: WebhookV2Status + + /** + * User provided description or name for the webhook. Max length 150 characters. + */ + description?: string +} + +/** + * Path parameters for DELETE /v2/webhooks/{webhook_id} + */ +export type DeleteWebhookPathParams = { + /** + * ID of webhook to delete + */ + webhook_id: string +} + +/** + * Path parameters for GET /v2/webhooks/{webhook_id} + */ +export type GetWebhookPathParams = { + /** + * ID of webhook to get + */ + webhook_id: string +} + +/** + * Path parameters for PUT /v2/webhooks/{webhook_id} + */ +export type PutWebhookPathParams = { + /** + * ID of webhook to update + */ + webhook_id: string +} + +/** + * Request body parameters for PUT /v2/webhooks/{webhook_id} + */ +export type PutWebhookRequestBody = { + event_type: WebhookV2Event + + /** + * The HTTP endpoint that will receive a POST request when the event triggers. Max length 2048 + * characters. + */ + endpoint: string + + /** + * String that will be passed back to your webhook endpoint to verify that it is being called by + * Figma. Max length 100 characters. + */ + passcode: string + + /** + * State of the webhook, including any error state it may be in + */ + status?: WebhookV2Status + + /** + * User provided description or name for the webhook. Max length 150 characters. + */ + description?: string +} + +/** + * Path parameters for GET /v2/teams/{team_id}/webhooks + */ +export type GetTeamWebhooksPathParams = { + /** + * ID of team to get webhooks for + */ + team_id: string +} + +/** + * Path parameters for GET /v2/webhooks/{webhook_id}/requests + */ +export type GetWebhookRequestsPathParams = { + /** + * The id of the webhook subscription you want to see events from + */ + webhook_id: string +} + +/** + * Query parameters for GET /v1/activity_logs + */ +export type GetActivityLogsQueryParams = { + /** + * Event type(s) to include in the response. Can have multiple values separated by comma. All + * events are returned by default. + */ + events?: string + /** + * Unix timestamp of the least recent event to include. This param defaults to one year ago if + * unspecified. + */ + start_time?: number + /** + * Unix timestamp of the most recent event to include. This param defaults to the current timestamp + * if unspecified. + */ + end_time?: number + /** + * Maximum number of events to return. This param defaults to 1000 if unspecified. + */ + limit?: number + /** + * Event order by timestamp. This param can be either "asc" (default) or "desc". + */ + order?: 'asc' | 'desc' +} + +/** + * Query parameters for GET /v1/payments + */ +export type GetPaymentsQueryParams = { + /** + * Short-lived token returned from "getPluginPaymentTokenAsync" in the plugin payments API and used + * to authenticate to this endpoint. Read more about generating this token through "Calling the + * Payments REST API from a plugin or widget" below. + */ + plugin_payment_token?: string + /** + * The ID of the user to query payment information about. You can get the user ID by having the user + * OAuth2 to the Figma REST API. + */ + user_id?: string + /** + * The ID of the Community file to query a user's payment information on. You can get the Community + * file ID from the file's Community page (look for the number after "file/" in the URL). Provide + * exactly one of "community_file_id", "plugin_id", or "widget_id". + */ + community_file_id?: string + /** + * The ID of the plugin to query a user's payment information on. You can get the plugin ID from the + * plugin's manifest, or from the plugin's Community page (look for the number after "plugin/" in + * the URL). Provide exactly one of "community_file_id", "plugin_id", or "widget_id". + */ + plugin_id?: string + /** + * The ID of the widget to query a user's payment information on. You can get the widget ID from the + * widget's manifest, or from the widget's Community page (look for the number after "widget/" in + * the URL). Provide exactly one of "community_file_id", "plugin_id", or "widget_id". + */ + widget_id?: string +} + +/** + * Path parameters for GET /v1/files/{file_key}/variables/local + */ +export type GetLocalVariablesPathParams = { + /** + * File to get variables from. This can be a file key or branch key. Use `GET /v1/files/:key` with + * the `branch_data` query param to get the branch key. + */ + file_key: string +} + +/** + * Path parameters for GET /v1/files/{file_key}/variables/published + */ +export type GetPublishedVariablesPathParams = { + /** + * File to get variables from. This must be a main file key, not a branch key, as it is not + * possible to publish from branches. + */ + file_key: string +} + +/** + * Path parameters for POST /v1/files/{file_key}/variables + */ +export type PostVariablesPathParams = { + /** + * File to modify variables in. This can be a file key or branch key. Use `GET /v1/files/:key` with + * the `branch_data` query param to get the branch key. + */ + file_key: string +} + +/** + * Request body parameters for POST /v1/files/{file_key}/variables + */ +export type PostVariablesRequestBody = { + /** + * For creating, updating, and deleting variable collections. + */ + variableCollections?: VariableCollectionChange[] + + /** + * For creating, updating, and deleting modes within variable collections. + */ + variableModes?: VariableModeChange[] + + /** + * For creating, updating, and deleting variables. + */ + variables?: VariableChange[] + + /** + * For setting a specific value, given a variable and a mode. + */ + variableModeValues?: VariableModeValue[] +} + +/** + * Path parameters for GET /v1/files/{file_key}/dev_resources + */ +export type GetDevResourcesPathParams = { + /** + * The file to get the dev resources from. This must be a main file key, not a branch key. + */ + file_key: string +} + +/** + * Query parameters for GET /v1/files/{file_key}/dev_resources + */ +export type GetDevResourcesQueryParams = { + /** + * Comma separated list of nodes that you care about in the document. If specified, only dev + * resources attached to these nodes will be returned. If not specified, all dev resources in the + * file will be returned. + */ + node_ids?: string +} + +/** + * Request body parameters for POST /v1/dev_resources + */ +export type PostDevResourcesRequestBody = { + /** + * An array of dev resources. + */ + dev_resources: { + /** + * The name of the dev resource. + */ + name: string + + /** + * The URL of the dev resource. + */ + url: string + + /** + * The file key where the dev resource belongs. + */ + file_key: string + + /** + * The target node to attach the dev resource to. + */ + node_id: string + }[] +} + +/** + * Request body parameters for PUT /v1/dev_resources + */ +export type PutDevResourcesRequestBody = { + /** + * An array of dev resources. + */ + dev_resources: { + /** + * Unique identifier of the dev resource + */ + id: string + + /** + * The name of the dev resource. + */ + name?: string + + /** + * The URL of the dev resource. + */ + url?: string + }[] +} + +/** + * Path parameters for DELETE /v1/files/{file_key}/dev_resources/{dev_resource_id} + */ +export type DeleteDevResourcePathParams = { + /** + * The file to delete the dev resource from. This must be a main file key, not a branch key. + */ + file_key: string + /** + * The id of the dev resource to delete. + */ + dev_resource_id: string +} + +/** + * Path parameters for GET /v1/analytics/libraries/{file_key}/component/actions + */ +export type GetLibraryAnalyticsComponentActionsPathParams = { + /** + * File key of the library to fetch analytics data for. + */ + file_key: string +} + +/** + * Query parameters for GET /v1/analytics/libraries/{file_key}/component/actions + */ +export type GetLibraryAnalyticsComponentActionsQueryParams = { + /** + * Cursor indicating what page of data to fetch. Obtained from prior API call. + */ + cursor?: string + /** + * A dimension to group returned analytics data by. + */ + group_by: 'component' | 'team' + /** + * ISO 8601 date string (YYYY-MM-DD) of the earliest week to include. Dates are rounded back to the + * nearest start of a week. Defaults to one year prior. + */ + start_date?: string + /** + * ISO 8601 date string (YYYY-MM-DD) of the latest week to include. Dates are rounded forward to the + * nearest end of a week. Defaults to the latest computed week. + */ + end_date?: string +} + +/** + * Path parameters for GET /v1/analytics/libraries/{file_key}/component/usages + */ +export type GetLibraryAnalyticsComponentUsagesPathParams = { + /** + * File key of the library to fetch analytics data for. + */ + file_key: string +} + +/** + * Query parameters for GET /v1/analytics/libraries/{file_key}/component/usages + */ +export type GetLibraryAnalyticsComponentUsagesQueryParams = { + /** + * Cursor indicating what page of data to fetch. Obtained from prior API call. + */ + cursor?: string + /** + * A dimension to group returned analytics data by. + */ + group_by: 'component' | 'file' +} + +/** + * Path parameters for GET /v1/analytics/libraries/{file_key}/style/actions + */ +export type GetLibraryAnalyticsStyleActionsPathParams = { + /** + * File key of the library to fetch analytics data for. + */ + file_key: string +} + +/** + * Query parameters for GET /v1/analytics/libraries/{file_key}/style/actions + */ +export type GetLibraryAnalyticsStyleActionsQueryParams = { + /** + * Cursor indicating what page of data to fetch. Obtained from prior API call. + */ + cursor?: string + /** + * A dimension to group returned analytics data by. + */ + group_by: 'style' | 'team' + /** + * ISO 8601 date string (YYYY-MM-DD) of the earliest week to include. Dates are rounded back to the + * nearest start of a week. Defaults to one year prior. + */ + start_date?: string + /** + * ISO 8601 date string (YYYY-MM-DD) of the latest week to include. Dates are rounded forward to the + * nearest end of a week. Defaults to the latest computed week. + */ + end_date?: string +} + +/** + * Path parameters for GET /v1/analytics/libraries/{file_key}/style/usages + */ +export type GetLibraryAnalyticsStyleUsagesPathParams = { + /** + * File key of the library to fetch analytics data for. + */ + file_key: string +} + +/** + * Query parameters for GET /v1/analytics/libraries/{file_key}/style/usages + */ +export type GetLibraryAnalyticsStyleUsagesQueryParams = { + /** + * Cursor indicating what page of data to fetch. Obtained from prior API call. + */ + cursor?: string + /** + * A dimension to group returned analytics data by. + */ + group_by: 'style' | 'file' +} + +/** + * Path parameters for GET /v1/analytics/libraries/{file_key}/variable/actions + */ +export type GetLibraryAnalyticsVariableActionsPathParams = { + /** + * File key of the library to fetch analytics data for. + */ + file_key: string +} + +/** + * Query parameters for GET /v1/analytics/libraries/{file_key}/variable/actions + */ +export type GetLibraryAnalyticsVariableActionsQueryParams = { + /** + * Cursor indicating what page of data to fetch. Obtained from prior API call. + */ + cursor?: string + /** + * A dimension to group returned analytics data by. + */ + group_by: 'variable' | 'team' + /** + * ISO 8601 date string (YYYY-MM-DD) of the earliest week to include. Dates are rounded back to the + * nearest start of a week. Defaults to one year prior. + */ + start_date?: string + /** + * ISO 8601 date string (YYYY-MM-DD) of the latest week to include. Dates are rounded forward to the + * nearest end of a week. Defaults to the latest computed week. + */ + end_date?: string +} + +/** + * Path parameters for GET /v1/analytics/libraries/{file_key}/variable/usages + */ +export type GetLibraryAnalyticsVariableUsagesPathParams = { + /** + * File key of the library to fetch analytics data for. + */ + file_key: string +} + +/** + * Query parameters for GET /v1/analytics/libraries/{file_key}/variable/usages + */ +export type GetLibraryAnalyticsVariableUsagesQueryParams = { + /** + * Cursor indicating what page of data to fetch. Obtained from prior API call. + */ + cursor?: string + /** + * A dimension to group returned analytics data by. + */ + group_by: 'variable' | 'file' +} diff --git a/.ref/figma/figma-rest-api.min.d.ts b/.ref/figma/figma-rest-api.min.d.ts new file mode 100644 index 0000000000..06c69dda6a --- /dev/null +++ b/.ref/figma/figma-rest-api.min.d.ts @@ -0,0 +1,2080 @@ +export type IsLayerTrait = { + /** + * A string uniquely identifying this node within the document. + */ + id: string; + + /** + * The name given to the node by the user in the tool. + */ + name: string; + + /** + * The type of the node + */ + type: string; + + /** + * Whether or not the node is visible on the canvas. + */ + visible?: boolean; + + /** + * If true, layer is locked and cannot be edited + */ + locked?: boolean; + + /** + * Whether the layer is fixed while the parent is scrolling + * + * @deprecated + */ + isFixed?: boolean; + + /** + * How layer should be treated when the frame is resized + */ + scrollBehavior: "SCROLLS" | "FIXED" | "STICKY_SCROLLS"; + + /** + * The rotation of the node, if not 0. + */ + rotation?: number; + + /** + * A mapping of a layer's property to component property name of component properties attached to + * this node. The component property name can be used to look up more information on the + * corresponding component's or component set's componentPropertyDefinitions. + */ + componentPropertyReferences?: { [key: string]: string }; + + /** + * Data written by plugins that is visible only to the plugin that wrote it. Requires the + * `pluginData` to include the ID of the plugin. + */ + pluginData?: unknown; + + /** + * Data written by plugins that is visible to all plugins. Requires the `pluginData` parameter to + * include the string "shared". + */ + sharedPluginData?: unknown; + + /** + * A mapping of variable collection ID to mode ID representing the explicitly set modes for this + * node. + */ + explicitVariableModes?: { [key: string]: string }; +}; + +export type HasChildrenTrait = { + /** + * An array of nodes that are direct children of this node + */ + children: SubcanvasNode[]; +}; + +export type HasLayoutTrait = { + /** + * Bounding box of the node in absolute space coordinates. + */ + absoluteBoundingBox: Rectangle | null; + + /** + * The actual bounds of a node accounting for drop shadows, thick strokes, and anything else that + * may fall outside the node's regular bounding box defined in `x`, `y`, `width`, and `height`. The + * `x` and `y` inside this property represent the absolute position of the node on the page. This + * value will be `null` if the node is invisible. + */ + absoluteRenderBounds: Rectangle | null; + + /** + * Keep height and width constrained to same ratio. + */ + preserveRatio?: boolean; + + /** + * Horizontal and vertical layout constraints for node. + */ + constraints?: LayoutConstraint; + + /** + * The top two rows of a matrix that represents the 2D transform of this node relative to its + * parent. The bottom row of the matrix is implicitly always (0, 0, 1). Use to transform coordinates + * in geometry. Only present if `geometry=paths` is passed. + */ + relativeTransform?: Transform; + + /** + * Width and height of element. This is different from the width and height of the bounding box in + * that the absolute bounding box represents the element after scaling and rotation. Only present if + * `geometry=paths` is passed. + */ + size?: Vector; + + /** + * Determines if the layer should stretch along the parent's counter axis. This property is only + * provided for direct children of auto-layout frames. + * + * - `INHERIT` + * - `STRETCH` + * + * In previous versions of auto layout, determined how the layer is aligned inside an auto-layout + * frame. This property is only provided for direct children of auto-layout frames. + * + * - `MIN` + * - `CENTER` + * - `MAX` + * - `STRETCH` + * + * In horizontal auto-layout frames, "MIN" and "MAX" correspond to "TOP" and "BOTTOM". In vertical + * auto-layout frames, "MIN" and "MAX" correspond to "LEFT" and "RIGHT". + */ + layoutAlign?: "INHERIT" | "STRETCH" | "MIN" | "CENTER" | "MAX"; + + /** + * This property is applicable only for direct children of auto-layout frames, ignored otherwise. + * Determines whether a layer should stretch along the parent's primary axis. A `0` corresponds to a + * fixed size and `1` corresponds to stretch. + */ + layoutGrow?: 0 | 1; + + /** + * Determines whether a layer's size and position should be determined by auto-layout settings or + * manually adjustable. + */ + layoutPositioning?: "AUTO" | "ABSOLUTE"; + + /** + * The minimum width of the frame. This property is only applicable for auto-layout frames or direct + * children of auto-layout frames. + */ + minWidth?: number; + + /** + * The maximum width of the frame. This property is only applicable for auto-layout frames or direct + * children of auto-layout frames. + */ + maxWidth?: number; + + /** + * The minimum height of the frame. This property is only applicable for auto-layout frames or + * direct children of auto-layout frames. + */ + minHeight?: number; + + /** + * The maximum height of the frame. This property is only applicable for auto-layout frames or + * direct children of auto-layout frames. + */ + maxHeight?: number; + + /** + * The horizontal sizing setting on this auto-layout frame or frame child. + * + * - `FIXED` + * - `HUG`: only valid on auto-layout frames and text nodes + * - `FILL`: only valid on auto-layout frame children + */ + layoutSizingHorizontal?: "FIXED" | "HUG" | "FILL"; + + /** + * The vertical sizing setting on this auto-layout frame or frame child. + * + * - `FIXED` + * - `HUG`: only valid on auto-layout frames and text nodes + * - `FILL`: only valid on auto-layout frame children + */ + layoutSizingVertical?: "FIXED" | "HUG" | "FILL"; +}; + +export type HasFramePropertiesTrait = { + /** + * Whether or not this node clip content outside of its bounds + */ + clipsContent: boolean; + + /** + * Background of the node. This is deprecated, as backgrounds for frames are now in the `fills` + * field. + * + * @deprecated + */ + background?: Paint[]; + + /** + * Background color of the node. This is deprecated, as frames now support more than a solid color + * as a background. Please use the `fills` field instead. + * + * @deprecated + */ + backgroundColor?: RGBA; + + /** + * An array of layout grids attached to this node (see layout grids section for more details). GROUP + * nodes do not have this attribute + */ + layoutGrids?: LayoutGrid[]; + + /** + * Whether a node has primary axis scrolling, horizontal or vertical. + */ + overflowDirection?: + | "HORIZONTAL_SCROLLING" + | "VERTICAL_SCROLLING" + | "HORIZONTAL_AND_VERTICAL_SCROLLING" + | "NONE"; + + /** + * Whether this layer uses auto-layout to position its children. + */ + layoutMode?: "NONE" | "HORIZONTAL" | "VERTICAL"; + + /** + * Whether the primary axis has a fixed length (determined by the user) or an automatic length + * (determined by the layout engine). This property is only applicable for auto-layout frames. + */ + primaryAxisSizingMode?: "FIXED" | "AUTO"; + + /** + * Whether the counter axis has a fixed length (determined by the user) or an automatic length + * (determined by the layout engine). This property is only applicable for auto-layout frames. + */ + counterAxisSizingMode?: "FIXED" | "AUTO"; + + /** + * Determines how the auto-layout frame's children should be aligned in the primary axis direction. + * This property is only applicable for auto-layout frames. + */ + primaryAxisAlignItems?: "MIN" | "CENTER" | "MAX" | "SPACE_BETWEEN"; + + /** + * Determines how the auto-layout frame's children should be aligned in the counter axis direction. + * This property is only applicable for auto-layout frames. + */ + counterAxisAlignItems?: "MIN" | "CENTER" | "MAX" | "BASELINE"; + + /** + * The padding between the left border of the frame and its children. This property is only + * applicable for auto-layout frames. + */ + paddingLeft?: number; + + /** + * The padding between the right border of the frame and its children. This property is only + * applicable for auto-layout frames. + */ + paddingRight?: number; + + /** + * The padding between the top border of the frame and its children. This property is only + * applicable for auto-layout frames. + */ + paddingTop?: number; + + /** + * The padding between the bottom border of the frame and its children. This property is only + * applicable for auto-layout frames. + */ + paddingBottom?: number; + + /** + * The distance between children of the frame. Can be negative. This property is only applicable for + * auto-layout frames. + */ + itemSpacing?: number; + + /** + * Determines the canvas stacking order of layers in this frame. When true, the first layer will be + * draw on top. This property is only applicable for auto-layout frames. + */ + itemReverseZIndex?: boolean; + + /** + * Determines whether strokes are included in layout calculations. When true, auto-layout frames + * behave like css "box-sizing: border-box". This property is only applicable for auto-layout + * frames. + */ + strokesIncludedInLayout?: boolean; + + /** + * Whether this auto-layout frame has wrapping enabled. + */ + layoutWrap?: "NO_WRAP" | "WRAP"; + + /** + * The distance between wrapped tracks of an auto-layout frame. This property is only applicable for + * auto-layout frames with `layoutWrap: "WRAP"` + */ + counterAxisSpacing?: number; + + /** + * Determines how the auto-layout frame’s wrapped tracks should be aligned in the counter axis + * direction. This property is only applicable for auto-layout frames with `layoutWrap: "WRAP"`. + */ + counterAxisAlignContent?: "AUTO" | "SPACE_BETWEEN"; +}; + +export type HasBlendModeAndOpacityTrait = { + /** + * How this node blends with nodes behind it in the scene (see blend mode section for more details) + */ + blendMode: BlendMode; + + /** + * Opacity of the node + */ + opacity?: number; +}; + +export type HasExportSettingsTrait = { + /** + * An array of export settings representing images to export from the node. + */ + exportSettings?: ExportSetting[]; +}; + +export type HasGeometryTrait = MinimalFillsTrait & + MinimalStrokesTrait & { + /** + * Map from ID to PaintOverride for looking up fill overrides. To see which regions are overriden, + * you must use the `geometry=paths` option. Each path returned may have an `overrideID` which maps + * to this table. + */ + fillOverrideTable?: { [key: string]: PaintOverride | null }; + + /** + * Only specified if parameter `geometry=paths` is used. An array of paths representing the object + * fill. + */ + fillGeometry?: Path[]; + + /** + * Only specified if parameter `geometry=paths` is used. An array of paths representing the object + * stroke. + */ + strokeGeometry?: Path[]; + + /** + * A string enum describing the end caps of vector paths. + */ + strokeCap?: + | "NONE" + | "ROUND" + | "SQUARE" + | "LINE_ARROW" + | "TRIANGLE_ARROW" + | "DIAMOND_FILLED" + | "CIRCLE_FILLED" + | "TRIANGLE_FILLED" + | "WASHI_TAPE_1" + | "WASHI_TAPE_2" + | "WASHI_TAPE_3" + | "WASHI_TAPE_4" + | "WASHI_TAPE_5" + | "WASHI_TAPE_6"; + + /** + * Only valid if `strokeJoin` is "MITER". The corner angle, in degrees, below which `strokeJoin` + * will be set to "BEVEL" to avoid super sharp corners. By default this is 28.96 degrees. + */ + strokeMiterAngle?: number; + }; + +export type MinimalFillsTrait = { + /** + * An array of fill paints applied to the node. + */ + fills: Paint[]; + + /** + * A mapping of a StyleType to style ID (see Style) of styles present on this node. The style ID can + * be used to look up more information about the style in the top-level styles field. + */ + styles?: { [key: string]: string }; +}; + +export type MinimalStrokesTrait = { + /** + * An array of stroke paints applied to the node. + */ + strokes?: Paint[]; + + /** + * The weight of strokes on the node. + */ + strokeWeight?: number; + + /** + * Position of stroke relative to vector outline, as a string enum + * + * - `INSIDE`: stroke drawn inside the shape boundary + * - `OUTSIDE`: stroke drawn outside the shape boundary + * - `CENTER`: stroke drawn centered along the shape boundary + */ + strokeAlign?: "INSIDE" | "OUTSIDE" | "CENTER"; + + /** + * A string enum with value of "MITER", "BEVEL", or "ROUND", describing how corners in vector paths + * are rendered. + */ + strokeJoin?: "MITER" | "BEVEL" | "ROUND"; + + /** + * An array of floating point numbers describing the pattern of dash length and gap lengths that the + * vector stroke will use when drawn. + * + * For example a value of [1, 2] indicates that the stroke will be drawn with a dash of length 1 + * followed by a gap of length 2, repeated. + */ + strokeDashes?: number[]; +}; + +export type IndividualStrokesTrait = { + /** + * An object including the top, bottom, left, and right stroke weights. Only returned if individual + * stroke weights are used. + */ + individualStrokeWeights?: StrokeWeights; +}; + +export type CornerTrait = { + /** + * Radius of each corner if a single radius is set for all corners + */ + cornerRadius?: number; + + /** + * A value that lets you control how "smooth" the corners are. Ranges from 0 to 1. 0 is the default + * and means that the corner is perfectly circular. A value of 0.6 means the corner matches the iOS + * 7 "squircle" icon shape. Other values produce various other curves. + */ + cornerSmoothing?: number; + + /** + * Array of length 4 of the radius of each corner of the frame, starting in the top left and + * proceeding clockwise. + * + * Values are given in the order top-left, top-right, bottom-right, bottom-left. + */ + rectangleCornerRadii?: number[]; +}; + +export type HasEffectsTrait = { + /** + * An array of effects attached to this node (see effects section for more details) + */ + effects: Effect[]; +}; + +export type HasMaskTrait = { + /** + * Does this node mask sibling nodes in front of it? + */ + isMask?: boolean; + + /** + * If this layer is a mask, this property describes the operation used to mask the layer's siblings. + * The value may be one of the following: + * + * - ALPHA: the mask node's alpha channel will be used to determine the opacity of each pixel in the + * masked result. + * - VECTOR: if the mask node has visible fill paints, every pixel inside the node's fill regions will + * be fully visible in the masked result. If the mask has visible stroke paints, every pixel + * inside the node's stroke regions will be fully visible in the masked result. + * - LUMINANCE: the luminance value of each pixel of the mask node will be used to determine the + * opacity of that pixel in the masked result. + */ + maskType?: "ALPHA" | "VECTOR" | "LUMINANCE"; + + /** + * True if maskType is VECTOR. This field is deprecated; use maskType instead. + * + * @deprecated + */ + isMaskOutline?: boolean; +}; + +export type ComponentPropertiesTrait = { + /** + * A mapping of name to `ComponentPropertyDefinition` for every component property on this + * component. Each property has a type, defaultValue, and other optional values. + */ + componentPropertyDefinitions?: { [key: string]: ComponentPropertyDefinition }; +}; + +export type TypePropertiesTrait = { + /** + * The raw characters in the text node. + */ + characters: string; + + /** + * Style of text including font family and weight. + */ + style: TypeStyle; + + /** + * The array corresponds to characters in the text box, where each element references the + * 'styleOverrideTable' to apply specific styles to each character. The array's length can be less + * than or equal to the number of characters due to the removal of trailing zeros. Elements with a + * value of 0 indicate characters that use the default type style. If the array is shorter than the + * total number of characters, the characters beyond the array's length also use the default style. + */ + characterStyleOverrides: number[]; + + /** + * Internal property, preserved for backward compatibility. Avoid using this value. + */ + layoutVersion?: number; + + /** + * Map from ID to TypeStyle for looking up style overrides. + */ + styleOverrideTable: { [key: string]: TypeStyle }; + + /** + * An array with the same number of elements as lines in the text node, where lines are delimited by + * newline or paragraph separator characters. Each element in the array corresponds to the list type + * of a specific line. List types are represented as string enums with one of these possible + * values: + * + * - `NONE`: Not a list item. + * - `ORDERED`: Text is an ordered list (numbered). + * - `UNORDERED`: Text is an unordered list (bulleted). + */ + lineTypes: ("NONE" | "ORDERED" | "UNORDERED")[]; + + /** + * An array with the same number of elements as lines in the text node, where lines are delimited by + * newline or paragraph separator characters. Each element in the array corresponds to the + * indentation level of a specific line. + */ + lineIndentations: number[]; +}; + +export type TextPathPropertiesTrait = { + /** + * The raw characters in the text path node. + */ + characters: string; + + /** + * Style of text including font family and weight. + */ + style: TextPathTypeStyle; + + /** + * The array corresponds to characters in the text box, where each element references the + * 'styleOverrideTable' to apply specific styles to each character. The array's length can be less + * than or equal to the number of characters due to the removal of trailing zeros. Elements with a + * value of 0 indicate characters that use the default type style. If the array is shorter than the + * total number of characters, the characters beyond the array's length also use the default style. + */ + characterStyleOverrides: number[]; + + /** + * Internal property, preserved for backward compatibility. Avoid using this value. + */ + layoutVersion?: number; + + /** + * Map from ID to TextPathTypeStyle for looking up style overrides. + */ + styleOverrideTable: { [key: string]: TextPathTypeStyle }; +}; + +export type HasTextSublayerTrait = { + /** + * Text contained within a text box. + */ + characters: string; +}; + +export type DevStatusTrait = { + /** + * Represents whether or not a node has a particular handoff (or dev) status applied to it. + */ + devStatus?: { + type: "NONE" | "READY_FOR_DEV" | "COMPLETED"; + + /** + * An optional field where the designer can add more information about the design and what has + * changed. + */ + description?: string; + }; +}; + +export type AnnotationsTrait = object; + +export type FrameTraits = IsLayerTrait & + HasBlendModeAndOpacityTrait & + HasChildrenTrait & + HasLayoutTrait & + HasFramePropertiesTrait & + CornerTrait & + HasGeometryTrait & + HasExportSettingsTrait & + HasEffectsTrait & + HasMaskTrait & + IndividualStrokesTrait & + DevStatusTrait & + AnnotationsTrait; + +export type DefaultShapeTraits = IsLayerTrait & + HasBlendModeAndOpacityTrait & + HasLayoutTrait & + HasGeometryTrait & + HasExportSettingsTrait & + HasEffectsTrait & + HasMaskTrait; + +export type CornerRadiusShapeTraits = DefaultShapeTraits & CornerTrait; + +export type RectangularShapeTraits = DefaultShapeTraits & + CornerTrait & + IndividualStrokesTrait & + AnnotationsTrait; + +export type Node = + | BooleanOperationNode + | ComponentNode + | ComponentSetNode + | EllipseNode + | FrameNode + | GroupNode + | InstanceNode + | LineNode + | RectangleNode + | RegularPolygonNode + | SectionNode + | SliceNode + | StarNode + | StickyNode + | TableNode + | TableCellNode + | TextNode + | TextPathNode + | TransformGroupNode + | VectorNode + | DocumentNode + | CanvasNode; + +export type DocumentNode = { + type: "DOCUMENT"; + + children: CanvasNode[]; +} & IsLayerTrait; + +export type CanvasNode = { + type: "CANVAS"; + + children: SubcanvasNode[]; + + /** + * Background color of the canvas. + */ + backgroundColor: RGBA; + + /** + * Node ID that corresponds to the start frame for prototypes. This is deprecated with the + * introduction of multiple flows. Please use the `flowStartingPoints` field. + * + * @deprecated + */ + prototypeStartNodeID: string | null; + + /** + * An array of flow starting points sorted by its position in the prototype settings panel. + */ + flowStartingPoints: FlowStartingPoint[]; + + /** + * The device used to view a prototype. + */ + prototypeDevice: PrototypeDevice; + + /** + * The background color of the prototype (currently only supports a single solid color paint). + */ + prototypeBackgrounds?: RGBA[]; +} & IsLayerTrait & + HasExportSettingsTrait; + +export type SubcanvasNode = + | BooleanOperationNode + | ComponentNode + | ComponentSetNode + | EllipseNode + | FrameNode + | GroupNode + | InstanceNode + | LineNode + | RectangleNode + | RegularPolygonNode + | SectionNode + | SliceNode + | StarNode + | StickyNode + | TableNode + | TableCellNode + | TextNode + | TextPathNode + | TransformGroupNode + | VectorNode; + +export type BooleanOperationNode = { + /** + * The type of this node, represented by the string literal "BOOLEAN_OPERATION" + */ + type: "BOOLEAN_OPERATION"; + + /** + * A string enum indicating the type of boolean operation applied. + */ + booleanOperation: "UNION" | "INTERSECT" | "SUBTRACT" | "EXCLUDE"; +} & IsLayerTrait & + HasBlendModeAndOpacityTrait & + HasChildrenTrait & + HasLayoutTrait & + HasGeometryTrait & + HasExportSettingsTrait & + HasEffectsTrait & + HasMaskTrait; + +export type SectionNode = { + /** + * The type of this node, represented by the string literal "SECTION" + */ + type: "SECTION"; + + /** + * Whether the contents of the section are visible + */ + sectionContentsHidden: boolean; +} & IsLayerTrait & + HasGeometryTrait & + HasChildrenTrait & + HasLayoutTrait & + DevStatusTrait; + +export type FrameNode = { + /** + * The type of this node, represented by the string literal "FRAME" + */ + type: "FRAME"; +} & FrameTraits; + +export type GroupNode = { + /** + * The type of this node, represented by the string literal "GROUP" + */ + type: "GROUP"; +} & FrameTraits; + +export type ComponentNode = { + /** + * The type of this node, represented by the string literal "COMPONENT" + */ + type: "COMPONENT"; +} & FrameTraits & + ComponentPropertiesTrait; + +export type ComponentSetNode = { + /** + * The type of this node, represented by the string literal "COMPONENT_SET" + */ + type: "COMPONENT_SET"; +} & FrameTraits & + ComponentPropertiesTrait; + +export type VectorNode = { + /** + * The type of this node, represented by the string literal "VECTOR" + */ + type: "VECTOR"; +} & CornerRadiusShapeTraits & + AnnotationsTrait; + +export type StarNode = { + /** + * The type of this node, represented by the string literal "STAR" + */ + type: "STAR"; +} & CornerRadiusShapeTraits & + AnnotationsTrait; + +export type LineNode = { + /** + * The type of this node, represented by the string literal "LINE" + */ + type: "LINE"; +} & DefaultShapeTraits & + AnnotationsTrait; + +export type EllipseNode = { + /** + * The type of this node, represented by the string literal "ELLIPSE" + */ + type: "ELLIPSE"; + + arcData: ArcData; +} & DefaultShapeTraits & + AnnotationsTrait; + +export type RegularPolygonNode = { + /** + * The type of this node, represented by the string literal "REGULAR_POLYGON" + */ + type: "REGULAR_POLYGON"; +} & CornerRadiusShapeTraits & + AnnotationsTrait; + +export type RectangleNode = { + /** + * The type of this node, represented by the string literal "RECTANGLE" + */ + type: "RECTANGLE"; +} & RectangularShapeTraits; + +export type TextNode = { + /** + * The type of this node, represented by the string literal "TEXT" + */ + type: "TEXT"; +} & DefaultShapeTraits & + TypePropertiesTrait & + AnnotationsTrait; + +export type TextPathNode = { + /** + * The type of this node, represented by the string literal "TEXT_PATH" + */ + type: "TEXT_PATH"; +} & DefaultShapeTraits & + TextPathPropertiesTrait; + +export type TableNode = { + /** + * The type of this node, represented by the string literal "TABLE" + */ + type: "TABLE"; +} & IsLayerTrait & + HasChildrenTrait & + HasLayoutTrait & + MinimalStrokesTrait & + HasEffectsTrait & + HasBlendModeAndOpacityTrait & + HasExportSettingsTrait; + +export type TableCellNode = { + /** + * The type of this node, represented by the string literal "TABLE_CELL" + */ + type: "TABLE_CELL"; +} & IsLayerTrait & + MinimalFillsTrait & + HasLayoutTrait & + HasTextSublayerTrait; + +export type TransformGroupNode = { + /** + * The type of this node, represented by the string literal "TRANSFORM_GROUP" + */ + type: "TRANSFORM_GROUP"; +} & FrameTraits; + +export type SliceNode = { + /** + * The type of this node, represented by the string literal "SLICE" + */ + type: "SLICE"; +} & IsLayerTrait; + +export type InstanceNode = { + /** + * The type of this node, represented by the string literal "INSTANCE" + */ + type: "INSTANCE"; + + /** + * ID of component that this instance came from. + */ + componentId: string; + + /** + * If true, this node has been marked as exposed to its containing component or component set. + */ + isExposedInstance?: boolean; + + /** + * IDs of instances that have been exposed to this node's level. + */ + exposedInstances?: string[]; + + /** + * A mapping of name to `ComponentProperty` for all component properties on this instance. Each + * property has a type, value, and other optional values. + */ + componentProperties?: { [key: string]: ComponentProperty }; + + /** + * An array of all of the fields directly overridden on this instance. Inherited overrides are not + * included. + */ + overrides: Overrides[]; +} & FrameTraits; + +export type StickyNode = { + /** + * The type of this node, represented by the string literal "STICKY" + */ + type: "STICKY"; + + /** + * If true, author name is visible. + */ + authorVisible?: boolean; +} & IsLayerTrait & + HasLayoutTrait & + HasBlendModeAndOpacityTrait & + MinimalFillsTrait & + HasMaskTrait & + HasEffectsTrait & + HasExportSettingsTrait & + HasTextSublayerTrait; + +/** + * An RGB color + */ +export type RGB = { + /** + * Red channel value, between 0 and 1. + */ + r: number; + + /** + * Green channel value, between 0 and 1. + */ + g: number; + + /** + * Blue channel value, between 0 and 1. + */ + b: number; +}; + +/** + * An RGBA color + */ +export type RGBA = { + /** + * Red channel value, between 0 and 1. + */ + r: number; + + /** + * Green channel value, between 0 and 1. + */ + g: number; + + /** + * Blue channel value, between 0 and 1. + */ + b: number; + + /** + * Alpha channel value, between 0 and 1. + */ + a: number; +}; + +/** + * A flow starting point used when launching a prototype to enter Presentation view. + */ +export type FlowStartingPoint = { + /** + * Unique identifier specifying the frame. + */ + nodeId: string; + + /** + * Name of flow. + */ + name: string; +}; + +/** + * A width and a height. + */ +export type Size = { + /** + * The width of a size. + */ + width: number; + + /** + * The height of a size. + */ + height: number; +}; + +/** + * The device used to view a prototype. + */ +export type PrototypeDevice = { + type: "NONE" | "PRESET" | "CUSTOM" | "PRESENTATION"; + + size?: Size; + + presetIdentifier?: string; + + rotation: "NONE" | "CCW_90"; +}; + +/** + * Sizing constraint for exports. + */ +export type Constraint = { + /** + * Type of constraint to apply: + * + * - `SCALE`: Scale by `value`. + * - `WIDTH`: Scale proportionally and set width to `value`. + * - `HEIGHT`: Scale proportionally and set height to `value`. + */ + type: "SCALE" | "WIDTH" | "HEIGHT"; + + /** + * See type property for effect of this field. + */ + value: number; +}; + +/** + * An export setting. + */ +export type ExportSetting = { + suffix: string; + + format: "JPG" | "PNG" | "SVG" | "PDF"; + + constraint: Constraint; +}; + +/** + * This type is a string enum with the following possible values + * + * Normal blends: + * + * - `PASS_THROUGH` (only applicable to objects with children) + * - `NORMAL` + * + * Darken: + * + * - `DARKEN` + * - `MULTIPLY` + * - `LINEAR_BURN` + * - `COLOR_BURN` + * + * Lighten: + * + * - `LIGHTEN` + * - `SCREEN` + * - `LINEAR_DODGE` + * - `COLOR_DODGE` + * + * Contrast: + * + * - `OVERLAY` + * - `SOFT_LIGHT` + * - `HARD_LIGHT` + * + * Inversion: + * + * - `DIFFERENCE` + * - `EXCLUSION` + * + * Component: + * + * - `HUE` + * - `SATURATION` + * - `COLOR` + * - `LUMINOSITY` + */ +export type BlendMode = + | "PASS_THROUGH" + | "NORMAL" + | "DARKEN" + | "MULTIPLY" + | "LINEAR_BURN" + | "COLOR_BURN" + | "LIGHTEN" + | "SCREEN" + | "LINEAR_DODGE" + | "COLOR_DODGE" + | "OVERLAY" + | "SOFT_LIGHT" + | "HARD_LIGHT" + | "DIFFERENCE" + | "EXCLUSION" + | "HUE" + | "SATURATION" + | "COLOR" + | "LUMINOSITY"; + +/** + * A 2d vector. + */ +export type Vector = { + /** + * X coordinate of the vector. + */ + x: number; + + /** + * Y coordinate of the vector. + */ + y: number; +}; + +/** + * A single color stop with its position along the gradient axis, color, and bound variables if any + */ +export type ColorStop = { + /** + * Value between 0 and 1 representing position along gradient axis. + */ + position: number; + + /** + * Color attached to corresponding position. + */ + color: RGBA; +}; + +/** + * A transformation matrix is standard way in computer graphics to represent translation and + * rotation. These are the top two rows of a 3x3 matrix. The bottom row of the matrix is assumed to + * be [0, 0, 1]. This is known as an affine transform and is enough to represent translation, + * rotation, and skew. + * + * The identity transform is [[1, 0, 0], [0, 1, 0]]. + * + * A translation matrix will typically look like: + * + * ;[ + * [1, 0, tx], + * [0, 1, ty], + * ] + * + * And a rotation matrix will typically look like: + * + * ;[ + * [cos(angle), sin(angle), 0], + * [-sin(angle), cos(angle), 0], + * ] + * + * Another way to think about this transform is as three vectors: + * + * - The x axis (t[0][0], t[1][0]) + * - The y axis (t[0][1], t[1][1]) + * - The translation offset (t[0][2], t[1][2]) + * + * The most common usage of the Transform matrix is the `relativeTransform property`. This + * particular usage of the matrix has a few additional restrictions. The translation offset can take + * on any value but we do enforce that the axis vectors are unit vectors (i.e. have length 1). The + * axes are not required to be at 90° angles to each other. + */ +export type Transform = number[][]; + +/** + * Image filters to apply to the node. + */ +export type ImageFilters = { + exposure?: number; + + contrast?: number; + + saturation?: number; + + temperature?: number; + + tint?: number; + + highlights?: number; + + shadows?: number; +}; + +export type BasePaint = { + /** + * Is the paint enabled? + */ + visible?: boolean; + + /** + * Overall opacity of paint (colors within the paint can also have opacity values which would blend + * with this) + */ + opacity?: number; + + /** + * How this node blends with nodes behind it in the scene + */ + blendMode: BlendMode; +}; + +export type SolidPaint = { + /** + * The string literal "SOLID" representing the paint's type. Always check the `type` before reading + * other properties. + */ + type: "SOLID"; + + /** + * Solid color of the paint + */ + color: RGBA; +} & BasePaint; + +export type GradientPaint = { + /** + * The string literal representing the paint's type. Always check the `type` before reading other + * properties. + */ + type: + | "GRADIENT_LINEAR" + | "GRADIENT_RADIAL" + | "GRADIENT_ANGULAR" + | "GRADIENT_DIAMOND"; + + /** + * This field contains three vectors, each of which are a position in normalized object space + * (normalized object space is if the top left corner of the bounding box of the object is (0, 0) + * and the bottom right is (1,1)). The first position corresponds to the start of the gradient + * (value 0 for the purposes of calculating gradient stops), the second position is the end of the + * gradient (value 1), and the third handle position determines the width of the gradient. + */ + gradientHandlePositions: Vector[]; + + /** + * Positions of key points along the gradient axis with the colors anchored there. Colors along the + * gradient are interpolated smoothly between neighboring gradient stops. + */ + gradientStops: ColorStop[]; +} & BasePaint; + +export type ImagePaint = { + /** + * The string literal "IMAGE" representing the paint's type. Always check the `type` before reading + * other properties. + */ + type: "IMAGE"; + + /** + * Image scaling mode. + */ + scaleMode: "FILL" | "FIT" | "TILE" | "STRETCH"; + + /** + * A reference to an image embedded in this node. To download the image using this reference, use + * the `GET file images` endpoint to retrieve the mapping from image references to image URLs. + */ + imageRef: string; + + /** + * Affine transform applied to the image, only present if `scaleMode` is `STRETCH` + */ + imageTransform?: Transform; + + /** + * Amount image is scaled by in tiling, only present if scaleMode is `TILE`. + */ + scalingFactor?: number; + + /** + * Defines what image filters have been applied to this paint, if any. If this property is not + * defined, no filters have been applied. + */ + filters?: ImageFilters; + + /** + * Image rotation, in degrees. + */ + rotation?: number; + + /** + * A reference to an animated GIF embedded in this node. To download the image using this reference, + * use the `GET file images` endpoint to retrieve the mapping from image references to image URLs. + */ + gifRef?: string; +} & BasePaint; + +export type PatternPaint = { + /** + * The string literal "PATTERN" representing the paint's type. Always check the `type` before + * reading other properties. + */ + type: "PATTERN"; + + /** + * The node id of the source node for the pattern + */ + sourceNodeId: string; + + /** + * The tile type for the pattern + */ + tileType: "RECTANGULAR" | "HORIZONTAL_HEXAGONAL" | "VERTICAL_HEXAGONAL"; + + /** + * The scaling factor for the pattern + */ + scalingFactor: number; + + /** + * The spacing for the pattern + */ + spacing: Vector; + + /** + * The horizontal alignment for the pattern + */ + horizontalAlignment: "START" | "CENTER" | "END"; + + /** + * The vertical alignment for the pattern + */ + verticalAlignment: "START" | "CENTER" | "END"; +} & BasePaint; + +export type Paint = SolidPaint | GradientPaint | ImagePaint | PatternPaint; + +/** + * Layout constraint relative to containing Frame + */ +export type LayoutConstraint = { + /** + * Vertical constraint (relative to containing frame) as an enum: + * + * - `TOP`: Node is laid out relative to top of the containing frame + * - `BOTTOM`: Node is laid out relative to bottom of the containing frame + * - `CENTER`: Node is vertically centered relative to containing frame + * - `TOP_BOTTOM`: Both top and bottom of node are constrained relative to containing frame (node + * stretches with frame) + * - `SCALE`: Node scales vertically with containing frame + */ + vertical: "TOP" | "BOTTOM" | "CENTER" | "TOP_BOTTOM" | "SCALE"; + + /** + * Horizontal constraint (relative to containing frame) as an enum: + * + * - `LEFT`: Node is laid out relative to left of the containing frame + * - `RIGHT`: Node is laid out relative to right of the containing frame + * - `CENTER`: Node is horizontally centered relative to containing frame + * - `LEFT_RIGHT`: Both left and right of node are constrained relative to containing frame (node + * stretches with frame) + * - `SCALE`: Node scales horizontally with containing frame + */ + horizontal: "LEFT" | "RIGHT" | "CENTER" | "LEFT_RIGHT" | "SCALE"; +}; + +/** + * A rectangle that expresses a bounding box in absolute coordinates. + */ +export type Rectangle = { + /** + * X coordinate of top left corner of the rectangle. + */ + x: number; + + /** + * Y coordinate of top left corner of the rectangle. + */ + y: number; + + /** + * Width of the rectangle. + */ + width: number; + + /** + * Height of the rectangle. + */ + height: number; +}; + +/** + * Guides to align and place objects within a frames. + */ +export type LayoutGrid = { + /** + * Orientation of the grid as a string enum + * + * - `COLUMNS`: Vertical grid + * - `ROWS`: Horizontal grid + * - `GRID`: Square grid + */ + pattern: "COLUMNS" | "ROWS" | "GRID"; + + /** + * Width of column grid or height of row grid or square grid spacing. + */ + sectionSize: number; + + /** + * Is the grid currently visible? + */ + visible: boolean; + + /** + * Color of the grid + */ + color: RGBA; + + /** + * Positioning of grid as a string enum + * + * - `MIN`: Grid starts at the left or top of the frame + * - `MAX`: Grid starts at the right or bottom of the frame + * - `STRETCH`: Grid is stretched to fit the frame + * - `CENTER`: Grid is center aligned + */ + alignment: "MIN" | "MAX" | "STRETCH" | "CENTER"; + + /** + * Spacing in between columns and rows + */ + gutterSize: number; + + /** + * Spacing before the first column or row + */ + offset: number; + + /** + * Number of columns or rows + */ + count: number; +}; + +/** + * Base properties shared by all shadow effects + */ +export type BaseShadowEffect = { + /** + * The color of the shadow + */ + color: RGBA; + + /** + * Blend mode of the shadow + */ + blendMode: BlendMode; + + /** + * How far the shadow is projected in the x and y directions + */ + offset: Vector; + + /** + * Radius of the blur effect (applies to shadows as well) + */ + radius: number; + + /** + * The distance by which to expand (or contract) the shadow. + * + * For drop shadows, a positive `spread` value creates a shadow larger than the node, whereas a + * negative value creates a shadow smaller than the node. + * + * For inner shadows, a positive `spread` value contracts the shadow. Spread values are only + * accepted on rectangles and ellipses, or on frames, components, and instances with visible fill + * paints and `clipsContent` enabled. When left unspecified, the default value is 0. + */ + spread?: number; + + /** + * Whether this shadow is visible. + */ + visible: boolean; +}; + +export type DropShadowEffect = { + /** + * A string literal representing the effect's type. Always check the type before reading other + * properties. + */ + type: "DROP_SHADOW"; + + /** + * Whether to show the shadow behind translucent or transparent pixels + */ + showShadowBehindNode: boolean; +} & BaseShadowEffect; + +export type InnerShadowEffect = { + /** + * A string literal representing the effect's type. Always check the type before reading other + * properties. + */ + type?: "INNER_SHADOW"; +} & BaseShadowEffect; + +export type BlurEffect = NormalBlurEffect | ProgressiveBlurEffect; + +/** + * Base properties shared by all blur effects + */ +export type BaseBlurEffect = { + /** + * A string literal representing the effect's type. Always check the type before reading other + * properties. + */ + type: "LAYER_BLUR" | "BACKGROUND_BLUR"; + + /** + * Whether this blur is active. + */ + visible: boolean; + + /** + * Radius of the blur effect + */ + radius: number; +}; + +export type NormalBlurEffect = { + /** + * The string literal 'NORMAL' representing the blur type. Always check the blurType before reading + * other properties. + */ + blurType?: "NORMAL"; +} & BaseBlurEffect; + +export type ProgressiveBlurEffect = { + /** + * The string literal 'PROGRESSIVE' representing the blur type. Always check the blurType before + * reading other properties. + */ + blurType: "PROGRESSIVE"; + + /** + * The starting radius of the progressive blur + */ + startRadius: number; + + /** + * The starting offset of the progressive blur + */ + startOffset: Vector; + + /** + * The ending offset of the progressive blur + */ + endOffset: Vector; +} & BaseBlurEffect; + +/** + * A texture effect + */ +export type TextureEffect = { + /** + * The string literal 'TEXTURE' representing the effect's type. Always check the type before reading + * other properties. + */ + type: "TEXTURE"; + + /** + * The size of the texture effect + */ + noiseSize: number; + + /** + * The radius of the texture effect + */ + radius: number; + + /** + * Whether the texture is clipped to the shape + */ + clipToShape: boolean; +}; + +export type MonotoneNoiseEffect = { + /** + * The string literal 'MONOTONE' representing the noise type. + */ + noiseType: "MONOTONE"; +} & BaseNoiseEffect; + +export type MultitoneNoiseEffect = { + /** + * The string literal 'MULTITONE' representing the noise type. + */ + noiseType: "MULTITONE"; + + /** + * The opacity of the noise effect + */ + opacity: number; +} & BaseNoiseEffect; + +export type DuotoneNoiseEffect = { + /** + * The string literal 'DUOTONE' representing the noise type. + */ + noiseType: "DUOTONE"; + + /** + * The secondary color of the noise effect + */ + secondaryColor: RGBA; +} & BaseNoiseEffect; + +/** + * A noise effect + */ +export type BaseNoiseEffect = { + /** + * The string literal 'NOISE' representing the effect's type. Always check the type before reading + * other properties. + */ + type: "NOISE"; + + /** + * Blend mode of the noise effect + */ + blendMode: BlendMode; + + /** + * The size of the noise effect + */ + noiseSize: number; + + /** + * The density of the noise effect + */ + density: number; +}; + +export type NoiseEffect = + | MonotoneNoiseEffect + | MultitoneNoiseEffect + | DuotoneNoiseEffect; + +export type Effect = + | DropShadowEffect + | InnerShadowEffect + | BlurEffect + | TextureEffect + | NoiseEffect; + +/** + * A set of properties that can be applied to nodes and published. Styles for a property can be + * created in the corresponding property's panel while editing a file. + */ +export type Style = { + /** + * The key of the style + */ + key: string; + + /** + * Name of the style + */ + name: string; + + /** + * Description of the style + */ + description: string; + + /** + * Whether this style is a remote style that doesn't live in this file + */ + remote: boolean; +}; + +/** + * This type is a string enum with the following possible values: + * + * - `EASE_IN`: Ease in with an animation curve similar to CSS ease-in. + * - `EASE_OUT`: Ease out with an animation curve similar to CSS ease-out. + * - `EASE_IN_AND_OUT`: Ease in and then out with an animation curve similar to CSS ease-in-out. + * - `LINEAR`: No easing, similar to CSS linear. + * - `EASE_IN_BACK`: Ease in with an animation curve that moves past the initial keyframe's value and + * then accelerates as it reaches the end. + * - `EASE_OUT_BACK`: Ease out with an animation curve that starts fast, then slows and goes past the + * ending keyframe's value. + * - `EASE_IN_AND_OUT_BACK`: Ease in and then out with an animation curve that overshoots the initial + * keyframe's value, then accelerates quickly before it slows and overshoots the ending keyframes + * value. + * - `CUSTOM_CUBIC_BEZIER`: User-defined cubic bezier curve. + * - `GENTLE`: Gentle animation similar to react-spring. + * - `QUICK`: Quick spring animation, great for toasts and notifications. + * - `BOUNCY`: Bouncy spring, for delightful animations like a heart bounce. + * - `SLOW`: Slow spring, useful as a steady, natural way to scale up fullscreen content. + * - `CUSTOM_SPRING`: User-defined spring animation. + */ +export type EasingType = + | "EASE_IN" + | "EASE_OUT" + | "EASE_IN_AND_OUT" + | "LINEAR" + | "EASE_IN_BACK" + | "EASE_OUT_BACK" + | "EASE_IN_AND_OUT_BACK" + | "CUSTOM_CUBIC_BEZIER" + | "GENTLE" + | "QUICK" + | "BOUNCY" + | "SLOW" + | "CUSTOM_SPRING"; + +/** + * Individual stroke weights + */ +export type StrokeWeights = { + /** + * The top stroke weight. + */ + top: number; + + /** + * The right stroke weight. + */ + right: number; + + /** + * The bottom stroke weight. + */ + bottom: number; + + /** + * The left stroke weight. + */ + left: number; +}; + +/** + * Paint metadata to override default paints. + */ +export type PaintOverride = { + /** + * Paints applied to characters. + */ + fills?: Paint[]; + + /** + * ID of style node, if any, that this inherits fill data from. + */ + inheritFillStyleId?: string; +}; + +/** + * Defines a single path + */ +export type Path = { + /** + * A series of path commands that encodes how to draw the path. + */ + path: string; + + /** + * The winding rule for the path (same as in SVGs). This determines whether a given point in space + * is inside or outside the path. + */ + windingRule: "NONZERO" | "EVENODD"; + + /** + * If there is a per-region fill, this refers to an ID in the `fillOverrideTable`. + */ + overrideID?: number; +}; + +/** + * Information about the arc properties of an ellipse. 0° is the x axis and increasing angles rotate + * clockwise. + */ +export type ArcData = { + /** + * Start of the sweep in radians. + */ + startingAngle: number; + + /** + * End of the sweep in radians. + */ + endingAngle: number; + + /** + * Inner radius value between 0 and 1 + */ + innerRadius: number; +}; + +/** + * A link to either a URL or another frame (node) in the document. + */ +export type Hyperlink = { + /** + * The type of hyperlink. Can be either `URL` or `NODE`. + */ + type: "URL" | "NODE"; + + /** + * The URL that the hyperlink points to, if `type` is `URL`. + */ + url?: string; + + /** + * The ID of the node that the hyperlink points to, if `type` is `NODE`. + */ + nodeID?: string; +}; + +export type BaseTypeStyle = { + /** + * Font family of text (standard name). + */ + fontFamily?: string; + + /** + * PostScript font name. + */ + fontPostScriptName?: string | null; + + /** + * Describes visual weight or emphasis, such as Bold or Italic. + */ + fontStyle?: string; + + /** + * Whether or not text is italicized. + */ + italic?: boolean; + + /** + * Numeric font weight. + */ + fontWeight?: number; + + /** + * Font size in px. + */ + fontSize?: number; + + /** + * Text casing applied to the node, default is the original casing. + */ + textCase?: "UPPER" | "LOWER" | "TITLE" | "SMALL_CAPS" | "SMALL_CAPS_FORCED"; + + /** + * Horizontal text alignment as string enum. + */ + textAlignHorizontal?: "LEFT" | "RIGHT" | "CENTER" | "JUSTIFIED"; + + /** + * Vertical text alignment as string enum. + */ + textAlignVertical?: "TOP" | "CENTER" | "BOTTOM"; + + /** + * Space between characters in px. + */ + letterSpacing?: number; + + /** + * An array of fill paints applied to the characters. + */ + fills?: Paint[]; + + /** + * Link to a URL or frame. + */ + hyperlink?: Hyperlink; + + /** + * A map of OpenType feature flags to 1 or 0, 1 if it is enabled and 0 if it is disabled. Note that + * some flags aren't reflected here. For example, SMCP (small caps) is still represented by the + * `textCase` field. + */ + opentypeFlags?: { [key: string]: number }; + + /** + * Indicates how the font weight was overridden when there is a text style override. + */ + semanticWeight?: "BOLD" | "NORMAL"; + + /** + * Indicates how the font style was overridden when there is a text style override. + */ + semanticItalic?: "ITALIC" | "NORMAL"; +}; + +export type TypeStyle = { + /** + * Space between paragraphs in px, 0 if not present. + */ + paragraphSpacing?: number; + + /** + * Paragraph indentation in px, 0 if not present. + */ + paragraphIndent?: number; + + /** + * Space between list items in px, 0 if not present. + */ + listSpacing?: number; + + /** + * Text decoration applied to the node, default is none. + */ + textDecoration?: "NONE" | "STRIKETHROUGH" | "UNDERLINE"; + + /** + * Dimensions along which text will auto resize, default is that the text does not auto-resize. + * TRUNCATE means that the text will be shortened and trailing text will be replaced with "…" if the + * text contents is larger than the bounds. `TRUNCATE` as a return value is deprecated and will be + * removed in a future version. Read from `textTruncation` instead. + */ + textAutoResize?: "NONE" | "WIDTH_AND_HEIGHT" | "HEIGHT" | "TRUNCATE"; + + /** + * Whether this text node will truncate with an ellipsis when the text contents is larger than the + * text node. + */ + textTruncation?: "DISABLED" | "ENDING"; + + /** + * When `textTruncation: "ENDING"` is set, `maxLines` determines how many lines a text node can grow + * to before it truncates. + */ + maxLines?: number; + + /** + * Line height in px. + */ + lineHeightPx?: number; + + /** + * Line height as a percentage of normal line height. This is deprecated; in a future version of the + * API only lineHeightPx and lineHeightPercentFontSize will be returned. + */ + lineHeightPercent?: number; + + /** + * Line height as a percentage of the font size. Only returned when `lineHeightPercent` (deprecated) + * is not 100. + */ + lineHeightPercentFontSize?: number; + + /** + * The unit of the line height value specified by the user. + */ + lineHeightUnit?: "PIXELS" | "FONT_SIZE_%" | "INTRINSIC_%"; + + /** + * Whether or not this style has overrides over a text style. The possible fields to override are + * semanticWeight, semanticItalic, hyperlink, and textDecoration. If this is true, then those fields + * are overrides if present. + */ + isOverrideOverTextStyle?: boolean; +} & BaseTypeStyle; + +export type TextPathTypeStyle = { + /** + * Whether or not this style has overrides over a text style. The possible fields to override are + * semanticWeight, semanticItalic, and hyperlink. If this is true, then those fields are overrides + * if present. + */ + isOverrideOverTextStyle?: boolean; +} & BaseTypeStyle; + +/** + * Component property type. + */ +export type ComponentPropertyType = + | "BOOLEAN" + | "INSTANCE_SWAP" + | "TEXT" + | "VARIANT"; + +/** + * Instance swap preferred value. + */ +export type InstanceSwapPreferredValue = { + /** + * Type of node for this preferred value. + */ + type: "COMPONENT" | "COMPONENT_SET"; + + /** + * Key of this component or component set. + */ + key: string; +}; + +/** + * A property of a component. + */ +export type ComponentPropertyDefinition = { + /** + * Type of this component property. + */ + type: ComponentPropertyType; + + /** + * Initial value of this property for instances. + */ + defaultValue: boolean | string; + + /** + * All possible values for this property. Only exists on VARIANT properties. + */ + variantOptions?: string[]; + + /** + * Preferred values for this property. Only applicable if type is `INSTANCE_SWAP`. + */ + preferredValues?: InstanceSwapPreferredValue[]; +}; + +/** + * A property of a component. + */ +export type ComponentProperty = { + /** + * Type of this component property. + */ + type: ComponentPropertyType; + + /** + * Value of the property for this component instance. + */ + value: boolean | string; + + /** + * Preferred values for this property. Only applicable if type is `INSTANCE_SWAP`. + */ + preferredValues?: InstanceSwapPreferredValue[]; +}; + +/** + * Fields directly overridden on an instance. Inherited overrides are not included. + */ +export type Overrides = { + /** + * A unique ID for a node. + */ + id: string; + + /** + * An array of properties. + */ + overriddenFields: string[]; +}; diff --git a/AGENTS.md b/AGENTS.md index 9331946146..82b8f66416 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,7 @@ Currently, we have below features / modules. - [docs](./docs) - the docs directory - [editor](./editor) - the editor directory +- [crates](./crates) - the rust crates directory - [packages](./packages) - shared packages - [desktop](./desktop) - the electron desktop app - [supabase](./supabase) - the supabase project @@ -32,10 +33,11 @@ Currently, we have below features / modules. - TypeScript 5 - main language for most apps - Python 3.12 - partially used for tasks / jobs, that are independent, e.g. `/library` - Deno - partially used for tasks / jobs, that shares the codebase, e.g. `/jobs` +- Rust (2024 edition) - used for wasm builds, mostly for graphics core. **Database** -Grida heavily relies on Supabase. +Grida heavily relies on Supabase (PostgreSQL). - Supabase @@ -50,6 +52,11 @@ Grida heavily relies on Supabase. - Shadcn UI - Lucide / Radix Icons +**Graphics Backend** + +- DOM - plain dom as canvas - for website builder canvas. (binded with react) +- Skia - the graphics backend - for 2D graphics. (binded with skia-safe) + **Desktop** - electron with electron-forge @@ -61,11 +68,19 @@ Documentation files are located in the `./docs` directory. This directory contains the docs as-is, the deployment of the docs are handled by [apps/docs](./apps/docs). A docusaurus project that syncs the docs content to its directory. When writing docs, the root `./docs` directory is the source of truth. +## `/crates/*` + +Importance: **High** + +monorepo rust crates. + +The rust implementation of the Grida Canvas. this is rapidly under development. - it will serve as our new rendering backend once it is stable. + ## `/editor` Importance: **Very high** -The editor is a monorepo project that contains the codebase for the editor. +The editor is a monorepo nextjs project that contains the codebase for the editor. grida.co and \[tenant\].grida.site domains are connected. @@ -128,10 +143,14 @@ Importance: **Low** Library workers are hosted on railway.com -## `/packages/grida-canvas-*` +## `/packages/*` Importance: **High** +monorepo node packages + +**`/packages/grida-canvas-*`** + Packages that powers the canvas. (some are published to npm, some are not) Since our project is in a rapid development, some large modules still lives under the `/editor` directory. Which will progressively move to `/packages` directory, once things are sorted out and fully defined with the good models. @@ -146,16 +165,31 @@ To run test, build, and dev, use below commands. ```sh # run tests -pnpm turbo test +turbo test + +# run tests for packages +turbo test --filter='./packages/*' + +# run tests except for rust crates +turbo test --filter='!./crates/*' # run build -pnpm turbo build +turbo build # run dev -pnpm turbo dev +turbo dev # run typecheck -pnpm turbo typecheck # fallback when build fails due to network issues (nextjs package might fail due to font fetching issues) +turbo typecheck # fallback when build fails due to network issues (nextjs package might fail due to font fetching issues) + +# for crates specific tests +cargo test + +# for crates specific build +cargo build + +# format crates +cargo fmt --all ``` Note: `typecheck` still rely on packages build artifacts, so it will fail if the build fails. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000000..17fa339078 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,4032 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ab_glyph" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec3672c180e71eeaaac3a541fbbc5f5ad4def8b747c595ad30d674e43049f7b0" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c71b1793ee61086797f5c80b6efa2b8ffa6d5dd703f118545808a7f2e27f7046" + +[[package]] +name = "addr2line" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.3", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "android-activity" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef6978589202a00cd7e118380c448a08b6ed394c3a8df3a430d0898e3a42d046" +dependencies = [ + "android-properties", + "bitflags 2.9.1", + "cc", + "cesu8", + "jni", + "jni-sys", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "num_enum", + "thiserror", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + +[[package]] +name = "anstream" +version = "0.6.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" + +[[package]] +name = "anstyle-parse" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6680de5231bd6ee4c6191b8a1325daa282b415391ec9d3a37bd34f2060dc73fa" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.59.0", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[package]] +name = "async-trait" +version = "0.1.88" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" + +[[package]] +name = "backtrace" +version = "0.3.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-targets 0.52.6", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bindgen" +version = "0.71.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3" +dependencies = [ + "bitflags 2.9.1", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.2", +] + +[[package]] +name = "bumpalo" +version = "3.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" + +[[package]] +name = "bytemuck" +version = "1.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9134a6ef01ce4b366b50689c94f82c14bc72bc5d0386829828a2e2752ef7958c" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" + +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.9.1", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + +[[package]] +name = "cc" +version = "1.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0fc897dc1e865cc67c0e05a836d9d3f1df3cbe442aa4a9473b18e12624a4951" +dependencies = [ + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "cg" +version = "0.0.0" +dependencies = [ + "async-trait", + "clap", + "console_error_panic_hook", + "criterion", + "figma-api", + "futures", + "gl", + "glutin", + "glutin-winit", + "math2", + "rand", + "raw-window-handle", + "reqwest", + "rstar", + "serde", + "serde_json", + "skia-safe", + "tokio", + "uuid", + "wasm-bindgen", + "winit", +] + +[[package]] +name = "cgl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff" +dependencies = [ + "libc", +] + +[[package]] +name = "chrono" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "clap" +version = "4.5.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd60e63e9be68e5fb56422e397cf9baddded06dae1d2e523401542383bc72a9f" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89cc6392a1f72bbeb820d71f32108f61fdaf18bc526e1d23954168a67759ef51" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" + +[[package]] +name = "colorchoice" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-graphics-types", + "foreign-types 0.5.0", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "criterion" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" +dependencies = [ + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "is-terminal", + "itertools 0.10.5", + "num-traits", + "once_cell", + "oorandom", + "plotters", + "rayon", + "regex", + "serde", + "serde_derive", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +dependencies = [ + "cast", + "itertools 0.10.5", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43da5946c66ffcc7745f48db692ffbb10a83bfe0afd96235c5c2a4fb23994929" + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "deranged" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" +dependencies = [ + "powerfmt", + "serde", +] + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +dependencies = [ + "bitflags 2.9.1", + "objc2 0.6.1", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dlib" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412" +dependencies = [ + "libloading", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cea14ef9355e3beab063703aa9dab15afd25f0667c341310c1e5274bb1d0da18" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "figma-api" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23256077c8160e009f3668de3dc68c41af8a97eee423fc09fd9396471f4da090" +dependencies = [ + "reqwest", + "serde", + "serde_json", + "serde_repr", + "serde_with", + "url", +] + +[[package]] +name = "filetime" +version = "0.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35c0522e981e68cbfa8c3f978441a5f34b30b96e146b33cd3359176b50fe8586" +dependencies = [ + "cfg-if", + "libc", + "libredox", + "windows-sys 0.59.0", +] + +[[package]] +name = "flate2" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ced92e76e966ca2fd84c8f7aa01a4aea65b0eb6648d72f7c8f3e2764a67fece" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared 0.3.1", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "gethostname" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0176e0459c2e4a1fe232f984bca6890e681076abb9934f6cea7c326f3fc47818" +dependencies = [ + "libc", + "windows-targets 0.48.5", +] + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.11.0+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasi 0.14.2+wasi-0.2.4", +] + +[[package]] +name = "gimli" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" + +[[package]] +name = "gl" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a94edab108827d67608095e269cf862e60d920f144a5026d3dbcfd8b877fb404" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glob" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" + +[[package]] +name = "glutin" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325" +dependencies = [ + "bitflags 2.9.1", + "cfg_aliases", + "cgl", + "dispatch2", + "glutin_egl_sys", + "glutin_glx_sys", + "glutin_wgl_sys", + "libloading", + "objc2 0.6.1", + "objc2-app-kit 0.3.1", + "objc2-core-foundation", + "objc2-foundation 0.3.1", + "once_cell", + "raw-window-handle", + "wayland-sys", + "windows-sys 0.52.0", + "x11-dl", +] + +[[package]] +name = "glutin-winit" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85edca7075f8fc728f28cb8fbb111a96c3b89e930574369e3e9c27eb75d3788f" +dependencies = [ + "cfg_aliases", + "glutin", + "raw-window-handle", + "winit", +] + +[[package]] +name = "glutin_egl_sys" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c4680ba6195f424febdc3ba46e7a42a0e58743f2edb115297b86d7f8ecc02d2" +dependencies = [ + "gl_generator", + "windows-sys 0.52.0", +] + +[[package]] +name = "glutin_glx_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7bb2938045a88b612499fbcba375a77198e01306f52272e692f8c1f3751185" +dependencies = [ + "gl_generator", + "x11-dl", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "grida" +version = "0.0.0" +dependencies = [ + "gl", + "skia-safe", +] + +[[package]] +name = "h2" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9421a676d1b147b16b82c9225157dc629087ef8ec4d5e2960f9437a90dac0a5" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.9.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "half" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "459196ed295495a68f7d7fe1d84f6c4b7ff0e21fe3017b2f283c6fac3ad803c9" +dependencies = [ + "cfg-if", + "crunchy", +] + +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84b26c544d002229e640969970a2e74021aadf6e2f96372b9c58eff97de08eb3" + +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "stable_deref_trait", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f154ce46856750ed433c8649605bf7ed2de3bc35fd9d2a9f30cddd873c80cb08" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "http" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03a01595e11bdcec50946522c32dde3fc6914743000a68b93000965f2f02406d" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c293b6b3d21eca78250dc7dbebd6b9210ec5530e038cbfe0661b5c47ab06e8" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" + +[[package]] +name = "icu_properties" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "potential_utf", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" + +[[package]] +name = "icu_provider" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" +dependencies = [ + "displaydoc", + "icu_locale_core", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e" +dependencies = [ + "equivalent", + "hashbrown 0.15.3", + "serde", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is-terminal" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys", + "log", + "thiserror", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + +[[package]] +name = "jobserver" +version = "0.1.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38f262f097c174adebe41eb73d66ae9c06b2844fb0da69969647bbddd9b0538a" +dependencies = [ + "getrandom 0.3.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.172" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" + +[[package]] +name = "libloading" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07033963ba89ebaf1584d767badaa2e8fcec21aedea6b8c0346d487d49c28667" +dependencies = [ + "cfg-if", + "windows-targets 0.53.0", +] + +[[package]] +name = "libm" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" + +[[package]] +name = "libredox" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +dependencies = [ + "bitflags 2.9.1", + "libc", + "redox_syscall 0.5.12", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" + +[[package]] +name = "litemap" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" + +[[package]] +name = "log" +version = "0.4.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" + +[[package]] +name = "math2" +version = "0.0.2" + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "memmap2" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3f7eed9d3848f8b98834af67102b720745c4ec028fcd0aa0239277e7de374f" +dependencies = [ + "libc", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be647b768db090acb35d5ec5db2b0e1f1de11133ca123b9eacf5137868f892a" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" +dependencies = [ + "libc", + "wasi 0.11.0+wasi-snapshot-preview1", + "windows-sys 0.59.0", +] + +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.9.1", + "jni-sys", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_enum" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e613fc340b2220f734a8595782c551f1250e969d87d3be1ae0579e8d4065179" +dependencies = [ + "num_enum_derive", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1844ef2428cc3e1cb900be36181049ef3d3193c63e43026cfe202983b27a56" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88c6597e14493ab2e44ce58f2fdecf095a51f12ca57bec060a11c57332520551" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.9.1", + "block2", + "libc", + "objc2 0.5.2", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation 0.2.2", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6f29f568bec459b0ddff777cec4fe3fd8666d82d5a40ebd0ff7e66134f89bcc" +dependencies = [ + "bitflags 2.9.1", + "objc2 0.6.1", + "objc2-core-foundation", + "objc2-foundation 0.3.1", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.9.1", + "block2", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.9.1", + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c10c2894a6fed806ade6027bcd50662746363a9589d3ec9d9bef30a4e4bc166" +dependencies = [ + "bitflags 2.9.1", + "dispatch2", + "objc2 0.6.1", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-contacts", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.9.1", + "block2", + "dispatch", + "libc", + "objc2 0.5.2", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900831247d2fe1a09a683278e5384cfb8c80c79fe6b166f9d14bfdde0ea1b03c" +dependencies = [ + "bitflags 2.9.1", + "objc2 0.6.1", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.9.1", + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.9.1", + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.9.1", + "block2", + "objc2 0.5.2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-image", + "objc2-core-location", + "objc2-foundation 0.2.2", + "objc2-link-presentation", + "objc2-quartz-core", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.9.1", + "block2", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "object" +version = "0.36.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" + +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + +[[package]] +name = "openssl" +version = "0.10.73" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8" +dependencies = [ + "bitflags 2.9.1", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-sys" +version = "0.9.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "orbclient" +version = "0.3.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba0b26cec2e24f08ed8bb31519a9333140a6599b867dac464bb150bdb796fd43" +dependencies = [ + "libredox", +] + +[[package]] +name = "owned_ttf_parser" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22ec719bbf3b2a81c109a4e20b1f129b5566b7dce654bc3872f6a05abf82b2c4" +dependencies = [ + "ttf-parser", +] + +[[package]] +name = "percent-encoding" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" + +[[package]] +name = "pin-project" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + +[[package]] +name = "polling" +version = "3.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b53a684391ad002dd6a596ceb6c74fd004fdce75f4be2e3f615068abbea5fd50" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.0.7", + "tracing", + "windows-sys 0.59.0", +] + +[[package]] +name = "potential_utf" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a7c30837279ca13e7c867e9e40053bc68740f988cb07f7ca6df43cc734b585" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dee91521343f4c5c6a63edd65e54f31f5c92fe8978c40a4282f8372194c6a7d" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro-crate" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edce586971a4dfaa28950c6f18ed55e0406c1ab88bbce2c6f6293a7aaba73d35" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" + +[[package]] +name = "rand" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.3", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "rayon" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928fca9cf2aa042393a8325b9ead81d2f0df4cb12e1e24cef072922ccd99c5af" +dependencies = [ + "bitflags 2.9.1", +] + +[[package]] +name = "regex" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + +[[package]] +name = "reqwest" +version = "0.12.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2f8e5513d63f2e5b386eb5106dc67eaf3f84e95258e210489136b8b92ad6119" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "ipnet", + "js-sys", + "log", + "mime", + "native-tls", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.16", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rstar" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "421400d13ccfd26dfa5858199c30a5d76f9c54e0dba7575273025b43c5175dbb" +dependencies = [ + "heapless", + "num-traits", + "smallvec", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.9.1", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c71e83d6afe7ff64890ec6b71d6a69bb8a610ab78ce364b3352876bb4c801266" +dependencies = [ + "bitflags 2.9.1", + "errno", + "libc", + "linux-raw-sys 0.9.4", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustls" +version = "0.23.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "730944ca083c1c233a75c09f199e973ca499344a2b7ba9e755c457e86fb4a321" +dependencies = [ + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4a72fe2bcf7a6ac6fd7d0b9e5cb68aeb7d4c0a0271730218b3e92d43b4eb435" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "sctk-adwaita" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" +dependencies = [ + "ab_glyph", + "log", + "memmap2", + "smithay-client-toolkit", + "tiny-skia", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.9.1", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "serde" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.140" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_spanned" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6b6f7f2fcb69f747921f79f3926bd1e203fce4fef62c268dd3abfb6d86029aa" +dependencies = [ + "base64", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.9.0", + "serde", + "serde_derive", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d00caa5193a3c8362ac2b73be6b9e768aa5a4b2f721d8f4b339600c3cb51f8e" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "skia-bindings" +version = "0.86.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbcd02d7008cdc4ac86b7d7461874c7ac1d2c38cad96629d7617c5d4b848acd0" +dependencies = [ + "bindgen", + "cc", + "flate2", + "heck", + "lazy_static", + "regex", + "serde_json", + "tar", + "toml", +] + +[[package]] +name = "skia-safe" +version = "0.86.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008dec8a6b69f03b2a0bc4520dc06a7a8efc844e59b2a9bc024f0cb02fb60b63" +dependencies = [ + "bitflags 2.9.1", + "lazy_static", + "skia-bindings", +] + +[[package]] +name = "slab" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" +dependencies = [ + "autocfg", +] + +[[package]] +name = "smallvec" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8917285742e9f3e1683f0a9c4e6b57960b7314d0b08d30d1ecd426713ee2eee9" + +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +dependencies = [ + "bitflags 2.9.1", + "calloop", + "calloop-wayland-source", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ce2b7fc941b3a24138a0a7cf8e858bfc6a992e7978a068a5c760deb0ed43caf" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags 2.9.1", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tar" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "tempfile" +version = "3.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" +dependencies = [ + "fastrand", + "getrandom 0.3.3", + "once_cell", + "rustix 1.0.7", + "windows-sys 0.59.0", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "time" +version = "0.3.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" + +[[package]] +name = "time-macros" +version = "0.2.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tinystr" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "tokio" +version = "1.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75ef51a33ef1da925cea3e4eb122833cb377c61439ca401b770f54902b806779" +dependencies = [ + "backtrace", + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.52.0", +] + +[[package]] +name = "tokio-macros" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66a539a9ad6d5d281510d5bd368c973d636c02dbf8a67300bfb6b950696ad7df" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05ae329d1f08c4d17a59bed7ff5b5a769d062e64a62d34a3261b219e62cd5aae" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3da5db5a963e24bc68be8b17b6fa82814bb22ee8660f192bb182771d498f09a3" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "310068873db2c5b3e7659d2cc35d21855dbafa50d1ce336397c666e3cb08137e" +dependencies = [ + "indexmap 2.9.0", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfb942dfe1d8e29a7ee7fcbde5bd2b9a25fb89aa70caea2eba3bee836ff41076" + +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" +dependencies = [ + "bitflags 2.9.1", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cf4199d1e5d15ddd86a694e4d0dffa9c323ce759fea589f00fef9d81cc1931d" +dependencies = [ + "getrandom 0.3.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasi" +version = "0.14.2+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +dependencies = [ + "wit-bindgen-rt", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wayland-backend" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe770181423e5fc79d3e2a7f4410b7799d5aab1de4372853de3c6aa13ca24121" +dependencies = [ + "cc", + "downcast-rs", + "rustix 0.38.44", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "978fa7c67b0847dbd6a9f350ca2569174974cd4082737054dbb7fbb79d7d9a61" +dependencies = [ + "bitflags 2.9.1", + "rustix 0.38.44", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.9.1", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a65317158dec28d00416cb16705934070aef4f8393353d41126c54264ae0f182" +dependencies = [ + "rustix 0.38.44", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "779075454e1e9a521794fed15886323ea0feda3f8b0fc1390f5398141310422a" +dependencies = [ + "bitflags 2.9.1", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fd38cdad69b56ace413c6bcc1fbf5acc5e2ef4af9d5f8f1f9570c0c83eae175" +dependencies = [ + "bitflags 2.9.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cb6cdc73399c0e06504c437fe3cf886f25568dd5454473d565085b36d6a8bbf" +dependencies = [ + "bitflags 2.9.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "896fdafd5d28145fce7958917d69f2fd44469b1d4e861cb5961bcbeebc6d1484" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbcebb399c77d5aa9fa5db874806ee7b4eba4e73650948e8f93963f128896615" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi-util" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-implement" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38" + +[[package]] +name = "windows-registry" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4286ad90ddb45071efd1a66dfa43eb02dd0dfbae1545ad6cc3c51cf34d7e8ba3" +dependencies = [ + "windows-result", + "windows-strings 0.3.1", + "windows-targets 0.53.0", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87fa48cc5d406560701792be122a10132491cff9d0aeb23583cc2dcafc847319" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e4c7e8ceaaf9cb7d7507c974735728ab453b67ef8f18febdd7c11fe59dca8b" +dependencies = [ + "windows_aarch64_gnullvm 0.53.0", + "windows_aarch64_msvc 0.53.0", + "windows_i686_gnu 0.53.0", + "windows_i686_gnullvm 0.53.0", + "windows_i686_msvc 0.53.0", + "windows_x86_64_gnu 0.53.0", + "windows_x86_64_gnullvm 0.53.0", + "windows_x86_64_msvc 0.53.0", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" + +[[package]] +name = "winit" +version = "0.30.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4409c10174df8779dc29a4788cac85ed84024ccbc1743b776b21a520ee1aaf4" +dependencies = [ + "ahash", + "android-activity", + "atomic-waker", + "bitflags 2.9.1", + "block2", + "bytemuck", + "calloop", + "cfg_aliases", + "concurrent-queue", + "core-foundation", + "core-graphics", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "sctk-adwaita", + "smithay-client-toolkit", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + +[[package]] +name = "winnow" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c06928c8748d81b05c9be96aad92e1b6ff01833332f281e8cfca3be4b35fc9ec" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen-rt" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" +dependencies = [ + "bitflags 2.9.1", +] + +[[package]] +name = "writeable" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d91ffca73ee7f68ce055750bf9f6eca0780b8c85eff9bc046a3b0da41755e12" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading", + "once_cell", + "rustix 0.38.44", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec107c4503ea0b4a98ef47356329af139c0a4f7750e621cf2973cd3385ebcb3d" + +[[package]] +name = "xattr" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d65cbf2f12c15564212d48f4e3dfb87923d25d611f2aed18f4cb23f0413d89e" +dependencies = [ + "libc", + "rustix 1.0.7", +] + +[[package]] +name = "xcursor" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ef33da6b1660b4ddbfb3aef0ade110c8b8a781a3b6382fa5f2b5b040fd55f61" + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.9.1", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "xml-rs" +version = "0.8.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a62ce76d9b56901b19a74f19431b0d8b3bc7ca4ad685a746dfd78ca8f4fc6bda" + +[[package]] +name = "yoke" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1702d9583232ddb9174e01bb7c15a2ab8fb1bc6f227aa1233858c351a3ba0cb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28a6e20d751156648aa063f3800b706ee209a32c0b4d9f24be3d980b01be55ef" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" + +[[package]] +name = "zerotrie" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a05eb080e015ba39cc9e23bbe5e7fb04d5fb040350f99f34e338d5fdd294428" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000000..0663057cd2 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,7 @@ +[workspace] +resolver = "3" +members = [ + "crates/grida", + "crates/grida-canvas", + "crates/grida-math2", +] \ No newline at end of file diff --git a/crates/.gitignore b/crates/.gitignore new file mode 100644 index 0000000000..87f6532f0a --- /dev/null +++ b/crates/.gitignore @@ -0,0 +1,21 @@ +# Generated by Cargo +# will have compiled files and executables +debug/ +target/ + +# These are backup files generated by rustfmt +**/*.rs.bk + +# MSVC Windows builds of rustc generate these, which store debugging information +*.pdb + +# Generated by cargo mutants +# Contains mutation testing data +**/mutants.out*/ + +# RustRover +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ \ No newline at end of file diff --git a/crates/Dockerfile b/crates/Dockerfile new file mode 100644 index 0000000000..6462b71b5e --- /dev/null +++ b/crates/Dockerfile @@ -0,0 +1,18 @@ +FROM ghcr.io/pragmatrix/rust-skia-linux:latest + +# Prepare Rust toolchain +RUN rustup update && \ + rustup default stable && \ + rustup target add wasm32-unknown-emscripten + +# ENV EMCC_CFLAGS="-s ERROR_ON_UNDEFINED_SYMBOLS=0 -s MAX_WEBGL_VERSION=2" +ENV EMCC_CFLAGS="-s ERROR_ON_UNDEFINED_SYMBOLS=0 -s MAX_WEBGL_VERSION=2 -s MODULARIZE=1 -s EXPORT_NAME=createRustSkiaModule -s EXPORTED_RUNTIME_METHODS=GL" + +WORKDIR /work +COPY . /work + +RUN source /emsdk/emsdk_env.sh && \ + cd grida && \ + cargo build --release --target wasm32-unknown-emscripten + +CMD ["bash"] diff --git a/crates/Makefile b/crates/Makefile new file mode 100644 index 0000000000..a5e7d4f3e1 --- /dev/null +++ b/crates/Makefile @@ -0,0 +1,22 @@ +IMAGE_NAME=grida-wasm-builder +CONTAINER_NAME=grida-build-container +WASM_TARGET=target/wasm32-unknown-emscripten +OUT_DIR=grida/target + +.PHONY: all build extract clean + +all: build extract + +build: + docker build -f Dockerfile -t $(IMAGE_NAME) . + +extract: + docker build -f Dockerfile -t $(IMAGE_NAME) . + -@docker rm -f $(CONTAINER_NAME) 2>/dev/null || true + mkdir -p $(OUT_DIR) + docker create --name $(CONTAINER_NAME) $(IMAGE_NAME) + docker cp $(CONTAINER_NAME):/work/grida/$(WASM_TARGET)/ $(OUT_DIR)/ + docker rm -f $(CONTAINER_NAME) + +clean: + rm -rf $(OUT_DIR)/* diff --git a/crates/docs/optimization.md b/crates/docs/optimization.md new file mode 100644 index 0000000000..67dd57c9cb --- /dev/null +++ b/crates/docs/optimization.md @@ -0,0 +1,226 @@ +# Rendering Optimization Strategies + +A summary of all discussed optimization techniques for achieving high-performance rendering (e.g., 144fps with large design documents). + +--- + +## Transform & Geometry + +1. **Transform Cache** + + - Store `local_transform` and derived `world_transform`. + - Use dirty flags and top-down updates. + +2. **Geometry Cache** + + - Cache `local_bounds`, `world_bounds`. + - Used for culling, layout, and hit-testing. + +3. **Flat Scene Graph + Parent Pointers** + + - Flat arena with parent/children relationships. + - Enables O(1) access and traversal. + +--- + +## Rendering Pipeline + +4. **GPU Acceleration (Skia Backend::GL/Vulkan)** + + - Use hardware compositing, filters, transforms. + +5. **Scene-Level Picture Caching** + + - Use `SkPicture` to record full-scene vector draw ops. + - Serves as the always-up-to-date canonical snapshot. + - Resolution-independent; ideal for rerendering or tile regeneration. + +6. **Tile-Based Raster Cache (Hybrid Rendering)** + + - Render the full viewport, take snapshot. debounced (after no more changes. e.g. 150ms) + - Divide the snapshot into fixed-size tiles (e.g., 512×512). + - When new area discovered, render the cached, non-overlapping parts with tile cache. only render newly discovered area. + - Repeat step 1. + - Optional padding per tile to account for effects (blur, shadows). + +7. **Dynamic Mode Switching (Picture vs Tile)** + + - Render from `SkPicture` directly during normal zoom or active edits. + - Fallback to raster tiles for zoomed-out or complex views. + - Tile invalidation/redraw is driven by zoom level, camera transform, or frame budget. + +8. **Dirty & Re-Cache Strategy** + + - Nodes marked dirty will trigger re-recording of affected picture regions or tiles. + - Use change tracking to only re-record minimum needed areas. + - Recording large subtrees is expensive—optimize granularity based on tree structure. + +9. **Scene Cache Config / Strategy** + + - Defines how scene caching is organized. + - Properties include: + + - `depth`: + + - `0` → Entire scene is one cache. + - `1` → Cache per top-level container. + - `n` → Cache at depth `n`, chunking deeper layers. + + - `mode`: `AlwaysPicture`, `Hybrid`, `AlwaysTile` + + - `tile_size`, `tile_padding` + + - `zoom_threshold_for_tiles` + + - `frame_budget_threshold_ms` + + - `use_bbh`, `enable_lod`, etc. + + - Cache accessors like `get_picture_cache_by_id()` support scoped re-rendering. + +10. **Will-Change Optimization** + + - Nodes marked with "will-change" are expected to become dirty soon. + - Examples: + + - Image node waiting on async src resolution + - Text node waiting on font availability + + - Tree holders of such nodes are chunked for localized re-recording. + - Prevents re-recording full subtrees—minimizes recording cost. + +11. **Flattened Render Command List** + + - Scene is compiled into a flat list of `RenderCommand` structs with resolved: + + - Transform + - Clip bounds + - Opacity + - Z-order + + - Enables non-recursive rendering and independent layer recording. + - Required for tiling at arbitrary depths and for caching subtrees. + + **Example:** + + ```text + Logical Tree: + Frame + └── Group + ├── Rect1 + ├── Rect2 + └── Rect3 + + Flattened: + [ + RenderCommand { node_id: Rect1, transform: ..., clip: ..., z: ... }, + RenderCommand { node_id: Rect2, transform: ..., clip: ..., z: ... }, + RenderCommand { node_id: Rect3, transform: ..., clip: ..., z: ... }, + ] + ``` + + - Each command can be grouped and recorded separately into its own `SkPicture`. + - Nesting is preserved logically via sort order, but rendering is flat. + - This model is essential for dynamic caching, parallel planning, and GPU-aware scheduling. + +12. **Dirty-Region Culling** + + - Use camera’s `visible_rect` to cull `world_bounds`. + - Optional: accelerate with quadtree or BVH. + +13. **Minimize Canvas State Changes** + + - Reuse transforms and paints. + - Precompute common values like DPI × Zoom × ViewMatrix. + +14. **Text & Path Caching** + + - Cache laid-out paragraphs and SkPaths. + - Avoid layout recomputation every frame. + +15. **Render Pass Flattening** + + - Group nodes with same blend/composite states. + - Sort draw calls for fewer GPU flushes. + +--- + +## Image Optimization + +16. **LoD / Mipmapped Image Swapping** + + - Use lower-res versions of images at low zoom. + - Prevents high GPU bandwidth use at low visibility. + +17. **ImageRepository with Transform-Aware Access** + + - Pick image resolution based on projected screen size. + - _TODO_: currently we select mipmap levels solely by the size of the + drawing rectangle. This is a temporary strategy until a proper cache + invalidation mechanism based on zoom is introduced. + +--- + +## Text & Glyph Optimization + +18. **Glyph Cache (Atlas or Paragraph Caching)** + + - Cache rasterized or vector glyphs used across the document. + - Prevents redundant layout or rendering of text. + - Essential for high-DPI or frequently zoomed views. + +--- + +## Engine-Level + +19. **Precomputed World Transforms** + + - Avoid recalculating transforms per draw call. + - Essential for random-access rendering. + +20. **Flat Table Architecture** + + - All node data (transforms, bounds, styles) stored in flat maps. + - Enables fast diffing, syncing, and concurrent access. + +21. **Callback-Based Traversal with Fn/FnMut** + + - Owner controls child behavior via inlined, zero-cost closures. + +22. **Scene Planner & Scheduler** + + - A dynamic system that builds the flat render list per frame. + - Reacts to scene changes, memory pressure, or frame budget changes. + - Drives the decision to re-record, cache, evict, or downgrade fidelity. + +--- + +## Optional Advanced + +23. **Multithreaded Scene Update** + + - Parallelize transform/bounds resolution. + +24. **CRDT-Ready Data Stores** + + - Flat table model enables future collaboration support. + +25. **BVH or Quadtree Spatial Index** + + - Build dynamic index from `world_bounds` for fast spatial queries. + +--- + +## With Compromises + +> Practical, UX-safe tradeoffs that simplify implementation and improve performance, especially under load. These techniques sacrifice exactness for speed — but in ways users won’t notice. + +--- + +- **Quantize Camera Transform** + + Instead of using fully continuous float precision for the camera position and zoom, round them to the nearest N units (e.g., 0.1 for position, 0.01 for zoom): + +--- + +This list is designed to help evolve a renderer from minimal single-threaded mode to scalable, GPU-friendly real-time performance. diff --git a/crates/fixtures/test-figma/766822741396935685.zip b/crates/fixtures/test-figma/766822741396935685.zip new file mode 100644 index 0000000000..33cdbb0108 Binary files /dev/null and b/crates/fixtures/test-figma/766822741396935685.zip differ diff --git a/crates/fixtures/test-figma/767108022358877208.zip b/crates/fixtures/test-figma/767108022358877208.zip new file mode 100644 index 0000000000..a37d6fb23e Binary files /dev/null and b/crates/fixtures/test-figma/767108022358877208.zip differ diff --git a/crates/fixtures/test-figma/767127152320102433.zip b/crates/fixtures/test-figma/767127152320102433.zip new file mode 100644 index 0000000000..06b13aa6b4 Binary files /dev/null and b/crates/fixtures/test-figma/767127152320102433.zip differ diff --git a/crates/fixtures/test-figma/767135333966182437.zip b/crates/fixtures/test-figma/767135333966182437.zip new file mode 100644 index 0000000000..66e1c39eb1 Binary files /dev/null and b/crates/fixtures/test-figma/767135333966182437.zip differ diff --git a/crates/fixtures/test-figma/767544357629075510.zip b/crates/fixtures/test-figma/767544357629075510.zip new file mode 100644 index 0000000000..7a1045b291 Binary files /dev/null and b/crates/fixtures/test-figma/767544357629075510.zip differ diff --git a/crates/fixtures/test-figma/768233500885703748.zip b/crates/fixtures/test-figma/768233500885703748.zip new file mode 100644 index 0000000000..2716afa838 Binary files /dev/null and b/crates/fixtures/test-figma/768233500885703748.zip differ diff --git a/crates/fixtures/test-figma/768273489419236430.zip b/crates/fixtures/test-figma/768273489419236430.zip new file mode 100644 index 0000000000..1819b15692 Binary files /dev/null and b/crates/fixtures/test-figma/768273489419236430.zip differ diff --git a/crates/grida-canvas/AGENTS.md b/crates/grida-canvas/AGENTS.md new file mode 100644 index 0000000000..bc337c3606 --- /dev/null +++ b/crates/grida-canvas/AGENTS.md @@ -0,0 +1,20 @@ +safe, high-performance, 2D real-time rendering engine. + +- uses [`skia-safe`](https://rust-skia.github.io/doc/skia_safe/) for painting +- uses [`math2`](../grida-math2/README.md) for geometry & common math operations + +## Testing & Development + +```sh +# run tests +cargo test + +# run fmt +cargo fmt + +# run build +cargo build + +# run dev (mostly requires window) +cargo run --example +``` diff --git a/crates/grida-canvas/Cargo.lock b/crates/grida-canvas/Cargo.lock new file mode 100644 index 0000000000..04ad23b8dd --- /dev/null +++ b/crates/grida-canvas/Cargo.lock @@ -0,0 +1,784 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "bindgen" +version = "0.71.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3" +dependencies = [ + "bitflags", + "cexpr", + "clang-sys", + "itertools", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn", +] + +[[package]] +name = "bitflags" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" + +[[package]] +name = "bumpalo" +version = "3.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" + +[[package]] +name = "cc" +version = "1.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0fc897dc1e865cc67c0e05a836d9d3f1df3cbe442aa4a9473b18e12624a4951" +dependencies = [ + "shlex", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfg-if" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "cg" +version = "0.1.0" +dependencies = [ + "console_error_panic_hook", + "skia-safe", + "wasm-bindgen", + "wee_alloc", +] + +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if 1.0.0", + "wasm-bindgen", +] + +[[package]] +name = "crc32fast" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" +dependencies = [ + "cfg-if 1.0.0", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cea14ef9355e3beab063703aa9dab15afd25f0667c341310c1e5274bb1d0da18" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "filetime" +version = "0.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35c0522e981e68cbfa8c3f978441a5f34b30b96e146b33cd3359176b50fe8586" +dependencies = [ + "cfg-if 1.0.0", + "libc", + "libredox", + "windows-sys", +] + +[[package]] +name = "flate2" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ced92e76e966ca2fd84c8f7aa01a4aea65b0eb6648d72f7c8f3e2764a67fece" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "glob" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" + +[[package]] +name = "hashbrown" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84b26c544d002229e640969970a2e74021aadf6e2f96372b9c58eff97de08eb3" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "indexmap" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.172" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" + +[[package]] +name = "libloading" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07033963ba89ebaf1584d767badaa2e8fcec21aedea6b8c0346d487d49c28667" +dependencies = [ + "cfg-if 1.0.0", + "windows-targets 0.53.0", +] + +[[package]] +name = "libredox" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +dependencies = [ + "bitflags", + "libc", + "redox_syscall", +] + +[[package]] +name = "linux-raw-sys" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" + +[[package]] +name = "log" +version = "0.4.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "memory_units" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8452105ba047068f40ff7093dd1d9da90898e63dd61736462e9cdda6a90ad3c3" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be647b768db090acb35d5ec5db2b0e1f1de11133ca123b9eacf5137868f892a" +dependencies = [ + "adler2", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "prettyplease" +version = "0.2.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dee91521343f4c5c6a63edd65e54f31f5c92fe8978c40a4282f8372194c6a7d" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928fca9cf2aa042393a8325b9ead81d2f0df4cb12e1e24cef072922ccd99c5af" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustix" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c71e83d6afe7ff64890ec6b71d6a69bb8a610ab78ce364b3352876bb4c801266" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "serde" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.140" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "serde_spanned" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1" +dependencies = [ + "serde", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "skia-bindings" +version = "0.86.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbcd02d7008cdc4ac86b7d7461874c7ac1d2c38cad96629d7617c5d4b848acd0" +dependencies = [ + "bindgen", + "cc", + "flate2", + "heck", + "lazy_static", + "regex", + "serde_json", + "tar", + "toml", +] + +[[package]] +name = "skia-safe" +version = "0.86.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008dec8a6b69f03b2a0bc4520dc06a7a8efc844e59b2a9bc024f0cb02fb60b63" +dependencies = [ + "bitflags", + "lazy_static", + "skia-bindings", +] + +[[package]] +name = "syn" +version = "2.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ce2b7fc941b3a24138a0a7cf8e858bfc6a992e7978a068a5c760deb0ed43caf" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tar" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "toml" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05ae329d1f08c4d17a59bed7ff5b5a769d062e64a62d34a3261b219e62cd5aae" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3da5db5a963e24bc68be8b17b6fa82814bb22ee8660f192bb182771d498f09a3" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "310068873db2c5b3e7659d2cc35d21855dbafa50d1ce336397c666e3cb08137e" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfb942dfe1d8e29a7ee7fcbde5bd2b9a25fb89aa70caea2eba3bee836ff41076" + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "wasm-bindgen" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +dependencies = [ + "cfg-if 1.0.0", + "once_cell", + "rustversion", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wee_alloc" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbb3b5a6b2bb17cb6ad44a2e68a43e8d2722c997da10e928665c72ec6c0a0b8e" +dependencies = [ + "cfg-if 0.1.10", + "libc", + "memory_units", + "winapi", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e4c7e8ceaaf9cb7d7507c974735728ab453b67ef8f18febdd7c11fe59dca8b" +dependencies = [ + "windows_aarch64_gnullvm 0.53.0", + "windows_aarch64_msvc 0.53.0", + "windows_i686_gnu 0.53.0", + "windows_i686_gnullvm 0.53.0", + "windows_i686_msvc 0.53.0", + "windows_x86_64_gnu 0.53.0", + "windows_x86_64_gnullvm 0.53.0", + "windows_x86_64_msvc 0.53.0", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" + +[[package]] +name = "winnow" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c06928c8748d81b05c9be96aad92e1b6ff01833332f281e8cfca3be4b35fc9ec" +dependencies = [ + "memchr", +] + +[[package]] +name = "xattr" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d65cbf2f12c15564212d48f4e3dfb87923d25d611f2aed18f4cb23f0413d89e" +dependencies = [ + "libc", + "rustix", +] diff --git a/crates/grida-canvas/Cargo.toml b/crates/grida-canvas/Cargo.toml new file mode 100644 index 0000000000..c3cb1b6258 --- /dev/null +++ b/crates/grida-canvas/Cargo.toml @@ -0,0 +1,50 @@ +[package] +name = "cg" +version = "0.0.0" +edition = "2024" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +# Core dependencies +serde = "1.0.219" +serde_json = "1.0.140" +uuid = { version = "1.17.0", features = ["v4", "js"] } +math2 = { path = "../grida-math2" } +skia-safe = { version = "0.86.0", features = ["gpu", "gl", "textlayout"] } +async-trait = "0.1" +rstar = "0.12" + +# WASM-specific dependencies +wasm-bindgen = { version = "0.2.100", optional = true } +console_error_panic_hook = { version = "0.1.7", optional = true } +figma-api = "0.31.3" +rand = "0.9.1" +futures = "0.3.31" + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +# Native-only dependencies +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } +reqwest = "0.12.19" +glutin = "0.32.0" +glutin-winit = "0.5.0" +raw-window-handle = "0.6.0" +gl-rs = { version = "0.14.0", package = "gl" } +winit = "0.30.0" +clap = { version = "4.5.39", features = ["derive"] } + +[features] +default = ["wasm-bindgen", "console_error_panic_hook"] +wasm = ["wasm-bindgen", "console_error_panic_hook"] + +[dev-dependencies] +criterion = "0.5" + +[[bench]] +name = "bench_rectangles" +harness = false + +[[bench]] +name = "bench_mipmap" +harness = false diff --git a/crates/grida-canvas/Makefile b/crates/grida-canvas/Makefile new file mode 100644 index 0000000000..4dad84a152 --- /dev/null +++ b/crates/grida-canvas/Makefile @@ -0,0 +1,10 @@ +EMSDK ?= ~/.asdf/installs/emsdk/$(shell asdf current emsdk | tr -s ' ' | cut -d ' ' -f 2) +BUILD = EMCC_CFLAGS="-s ERROR_ON_UNDEFINED_SYMBOLS=0 -s MAX_WEBGL_VERSION=2 -s MODULARIZE=1 -s EXPORT_NAME=createRustSkiaModule -s EXPORTED_RUNTIME_METHODS=GL" EMSDK=$(EMSDK) cargo build --target wasm32-unknown-emscripten + +.PHONY: build +build: + $(BUILD) + +.PHONY: build_release +build_release: + $(BUILD) --release diff --git a/crates/grida-canvas/README.md b/crates/grida-canvas/README.md new file mode 100644 index 0000000000..0a9b0335ec --- /dev/null +++ b/crates/grida-canvas/README.md @@ -0,0 +1,58 @@ +# grida-canvas (`cg`) + +Grida Canvas Rendering Backend Example + +## Build + +```bash +make build +make build_release +``` + +## Rendering + +**2D Nodes** + +- [ ] TextSpan +- [ ] Text (Text with mixed styles) +- [ ] Image +- [ ] Bitmap (for bitmap drawing) +- [ ] Group +- [ ] Container (Frame) +- [ ] Rectangle +- [ ] Ellipse +- [ ] Polygon +- [ ] RegularPolygon +- [ ] RegularStarPolygon +- [ ] Path (SVG Path) +- [ ] Vector (Vector Network) +- [ ] Line + +**Meta** + +- [ ] Mask +- [ ] Clip + +**Styles & Effects** + +- [ ] SolidPaint +- [ ] LinearGradientPaint +- [ ] RadialGradientPaint +- [ ] DropShadow +- [ ] BoxShadow +- [ ] BlendMode + +## API + +**Camera** + +- [ ] 2D Camera + +**Pipeline & API** + +- [ ] load font +- [ ] load image + +## Interactivity + +- [ ] Hit testing diff --git a/crates/grida-canvas/benches/bench_mipmap.rs b/crates/grida-canvas/benches/bench_mipmap.rs new file mode 100644 index 0000000000..b6905f01d1 --- /dev/null +++ b/crates/grida-canvas/benches/bench_mipmap.rs @@ -0,0 +1,35 @@ +use cg::mipmap::{ImageMipmaps, MipmapConfig, MipmapLevels}; +use criterion::{Criterion, black_box, criterion_group, criterion_main}; +use skia_safe::surfaces; + +fn bench_mipmap_generation(c: &mut Criterion) { + let mut surface = surfaces::raster_n32_premul((2048, 2048)).unwrap(); + let image = surface.image_snapshot(); + + let mut group = c.benchmark_group("mipmap_gen"); + + let chained = MipmapConfig { + levels: MipmapLevels::FullChain, + chained: true, + }; + group.bench_function("chained", |b| { + b.iter(|| { + let _ = ImageMipmaps::from_image(black_box(image.clone()), &chained); + }) + }); + + let direct = MipmapConfig { + levels: MipmapLevels::FullChain, + chained: false, + }; + group.bench_function("direct", |b| { + b.iter(|| { + let _ = ImageMipmaps::from_image(black_box(image.clone()), &direct); + }) + }); + + group.finish(); +} + +criterion_group!(benches, bench_mipmap_generation); +criterion_main!(benches); diff --git a/crates/grida-canvas/benches/bench_rectangles.rs b/crates/grida-canvas/benches/bench_rectangles.rs new file mode 100644 index 0000000000..b6df145db3 --- /dev/null +++ b/crates/grida-canvas/benches/bench_rectangles.rs @@ -0,0 +1,220 @@ +use cg::node::repository::NodeRepository; +use cg::node::schema::*; +use cg::runtime::scene::{Backend, Renderer}; +use criterion::{Criterion, black_box, criterion_group, criterion_main}; +use math2::transform::AffineTransform; + +fn create_rectangles(count: usize, with_effects: bool) -> Scene { + let mut repository = NodeRepository::new(); + let mut ids = Vec::new(); + + // Create rectangles + for i in 0..count { + let id = format!("rect-{}", i); + ids.push(id.clone()); + + let rect = RectangleNode { + base: BaseNode { + id: id.clone(), + name: format!("Rectangle {}", i), + active: true, + }, + transform: AffineTransform::identity(), + size: Size { + width: 100.0, + height: 100.0, + }, + corner_radius: RectangularCornerRadius::zero(), + fill: Paint::Solid(SolidPaint { + color: Color(255, 0, 0, 255), + opacity: 1.0, + }), + stroke: Paint::Solid(SolidPaint { + color: Color(0, 0, 0, 255), + opacity: 1.0, + }), + stroke_width: 1.0, + stroke_align: StrokeAlign::Inside, + stroke_dash_array: None, + opacity: 1.0, + blend_mode: BlendMode::Normal, + effect: if with_effects { + Some(FilterEffect::DropShadow(FeDropShadow { + dx: 2.0, + dy: 2.0, + blur: 4.0, + color: Color(0, 0, 0, 128), + })) + } else { + None + }, + }; + + repository.insert(Node::Rectangle(rect)); + } + + // Create root group + let root_group = GroupNode { + base: BaseNode { + id: "root".to_string(), + name: "Root Group".to_string(), + active: true, + }, + transform: AffineTransform::identity(), + children: ids.clone(), + opacity: 1.0, + blend_mode: BlendMode::Normal, + }; + + repository.insert(Node::Group(root_group)); + + Scene { + id: "scene".to_string(), + name: "Test Scene".to_string(), + transform: AffineTransform::identity(), + children: vec!["root".to_string()], + nodes: repository, + background_color: None, + } +} + +fn bench_rectangles(c: &mut Criterion) { + let width = 1000; + let height = 1000; + + let mut group = c.benchmark_group("rectangles"); + group.sample_size(100); + group.measurement_time(std::time::Duration::from_secs(10)); + + // 1K rectangles + group.bench_function("1k_basic", |b| { + b.iter(|| { + let mut renderer = Renderer::new(); + let surface_ptr = Renderer::init_raster(width, height); + renderer.set_backend(Backend::Raster(surface_ptr)); + + let scene = create_rectangles(black_box(1_000), false); + + // Clear canvas + let surface = unsafe { &mut *surface_ptr }; + let canvas = surface.canvas(); + let mut paint = skia_safe::Paint::default(); + paint.set_color(skia_safe::Color::WHITE); + canvas.draw_rect( + skia_safe::Rect::from_xywh(0.0, 0.0, width as f32, height as f32), + &paint, + ); + + renderer.load_scene(scene); + renderer.queue(); + renderer.free(); + }) + }); + + // 10K rectangles + group.bench_function("10k_basic", |b| { + b.iter(|| { + let mut renderer = Renderer::new(); + + let surface_ptr = Renderer::init_raster(width, height); + renderer.set_backend(Backend::Raster(surface_ptr)); + + let scene = create_rectangles(black_box(10_000), false); + + // Clear canvas + let surface = unsafe { &mut *surface_ptr }; + let canvas = surface.canvas(); + let mut paint = skia_safe::Paint::default(); + paint.set_color(skia_safe::Color::WHITE); + canvas.draw_rect( + skia_safe::Rect::from_xywh(0.0, 0.0, width as f32, height as f32), + &paint, + ); + + renderer.load_scene(scene); + renderer.queue(); + renderer.free(); + }) + }); + + group.bench_function("10k_with_effects", |b| { + b.iter(|| { + let mut renderer = Renderer::new(); + + let surface_ptr = Renderer::init_raster(width, height); + renderer.set_backend(Backend::Raster(surface_ptr)); + + let scene = create_rectangles(black_box(10_000), true); + + // Clear canvas + let surface = unsafe { &mut *surface_ptr }; + let canvas = surface.canvas(); + let mut paint = skia_safe::Paint::default(); + paint.set_color(skia_safe::Color::WHITE); + canvas.draw_rect( + skia_safe::Rect::from_xywh(0.0, 0.0, width as f32, height as f32), + &paint, + ); + + renderer.load_scene(scene); + renderer.queue(); + renderer.free(); + }) + }); + + // 50K rectangles + group.bench_function("50k_basic", |b| { + b.iter(|| { + let mut renderer = Renderer::new(); + + let surface_ptr = Renderer::init_raster(width, height); + renderer.set_backend(Backend::Raster(surface_ptr)); + + let scene = create_rectangles(black_box(50_000), false); + + // Clear canvas + let surface = unsafe { &mut *surface_ptr }; + let canvas = surface.canvas(); + let mut paint = skia_safe::Paint::default(); + paint.set_color(skia_safe::Color::WHITE); + canvas.draw_rect( + skia_safe::Rect::from_xywh(0.0, 0.0, width as f32, height as f32), + &paint, + ); + + renderer.load_scene(scene); + renderer.queue(); + renderer.free(); + }) + }); + + group.bench_function("50k_with_effects", |b| { + b.iter(|| { + let mut renderer = Renderer::new(); + + let surface_ptr = Renderer::init_raster(width, height); + renderer.set_backend(Backend::Raster(surface_ptr)); + + let scene = create_rectangles(black_box(50_000), true); + + // Clear canvas + let surface = unsafe { &mut *surface_ptr }; + let canvas = surface.canvas(); + let mut paint = skia_safe::Paint::default(); + paint.set_color(skia_safe::Color::WHITE); + canvas.draw_rect( + skia_safe::Rect::from_xywh(0.0, 0.0, width as f32, height as f32), + &paint, + ); + + renderer.load_scene(scene); + renderer.queue(); + renderer.free(); + }) + }); + + group.finish(); +} + +criterion_group!(benches, bench_rectangles); +criterion_main!(benches); diff --git a/crates/grida-canvas/cover.png b/crates/grida-canvas/cover.png new file mode 100644 index 0000000000..5e6146d33e Binary files /dev/null and b/crates/grida-canvas/cover.png differ diff --git a/crates/grida-canvas/examples/100k.rs b/crates/grida-canvas/examples/100k.rs new file mode 100644 index 0000000000..31fdcbcb70 --- /dev/null +++ b/crates/grida-canvas/examples/100k.rs @@ -0,0 +1,104 @@ +use cg::node::factory::NodeFactory; +use cg::node::repository::NodeRepository; +use cg::node::schema::*; +use cg::window; +use math2::transform::AffineTransform; + +async fn demo_n_shapes(n: usize) -> Scene { + let nf = NodeFactory::new(); + + let mut repository = NodeRepository::new(); + let mut all_shape_ids = Vec::new(); + + // Grid parameters + let shape_size = 100.0; // Fixed size of 100x100 per shape + let spacing = 10.0; // Space between shapes + + // Calculate grid dimensions to make it as square as possible + let grid_width = (n as f32).sqrt().ceil() as i32; + let grid_height = (n as f32 / grid_width as f32).ceil() as i32; + + // Calculate starting position (top-left) + let start_x = 0.0; + let start_y = 0.0; + + // Generate shapes in a grid pattern + for i in 0..n { + let row = (i as i32) / grid_width; + let col = (i as i32) % grid_width; + + let mut rect = nf.create_rectangle_node(); + rect.base.name = format!("Shape_{}", i); + rect.transform = AffineTransform::new( + start_x + (col as f32 * (shape_size + spacing)), + start_y + (row as f32 * (shape_size + spacing)), + 0.0, + ); + rect.size = Size { + width: shape_size, + height: shape_size, + }; + rect.corner_radius = RectangularCornerRadius::all(10.0); + + // Create rainbow effect from top-left to bottom-right + // Calculate diagonal position (0.0 to 1.0 across the diagonal) + let diagonal_progress = (row + col) as f32 / (grid_height + grid_width - 2) as f32; + + // Convert to hue (0-360 degrees) + let hue = diagonal_progress * 360.0; + + // Convert HSV to RGB + let (r, g, b) = hsv_to_rgb(hue, 1.0, 1.0); + + rect.fill = Paint::Solid(SolidPaint { + color: Color(r, g, b, 255), + opacity: 1.0, + }); + + all_shape_ids.push(rect.base.id.clone()); + repository.insert(Node::Rectangle(rect)); + } + + Scene { + id: "scene".to_string(), + name: format!("{} Shapes Performance Test", n), + transform: AffineTransform::identity(), + children: all_shape_ids, + nodes: repository, + background_color: None, + } +} + +// Helper function to convert HSV to RGB +fn hsv_to_rgb(h: f32, s: f32, v: f32) -> (u8, u8, u8) { + let h = h % 360.0; + let c = v * s; + let x = c * (1.0 - ((h / 60.0) % 2.0 - 1.0).abs()); + let m = v - c; + + let (r, g, b) = if h < 60.0 { + (c, x, 0.0) + } else if h < 120.0 { + (x, c, 0.0) + } else if h < 180.0 { + (0.0, c, x) + } else if h < 240.0 { + (0.0, x, c) + } else if h < 300.0 { + (x, 0.0, c) + } else { + (c, 0.0, x) + }; + + ( + ((r + m) * 255.0) as u8, + ((g + m) * 255.0) as u8, + ((b + m) * 255.0) as u8, + ) +} + +#[tokio::main] +async fn main() { + let scene = demo_n_shapes(100000).await; + window::run_demo_window(scene).await; +} diff --git a/crates/grida-canvas/examples/basic.rs b/crates/grida-canvas/examples/basic.rs new file mode 100644 index 0000000000..8a157193c6 --- /dev/null +++ b/crates/grida-canvas/examples/basic.rs @@ -0,0 +1,240 @@ +use cg::node::factory::NodeFactory; +use cg::node::repository::NodeRepository; +use cg::node::schema::*; +use cg::window; +use math2::transform::AffineTransform; + +async fn demo_basic() -> Scene { + let font_caveat_family = "Caveat".to_string(); + + let nf = NodeFactory::new(); + + // Preload image before timing + let demo_image_id = "demo_image"; + + // Create a test image node with URL + let mut image_node = nf.create_image_node(); + image_node.base.name = "Test Image".to_string(); + image_node.transform = AffineTransform::new(50.0, 50.0, 0.0); + image_node.size = Size { + width: 200.0, + height: 200.0, + }; + image_node.corner_radius = RectangularCornerRadius::all(20.0); + image_node.stroke_width = 2.0; + image_node.effect = Some(FilterEffect::DropShadow(FeDropShadow { + dx: 4.0, + dy: 4.0, + blur: 8.0, + color: Color(0, 0, 0, 77), + })); + image_node._ref = demo_image_id.to_string(); + + // Create a test rectangle node with linear gradient + let mut rect_node = nf.create_rectangle_node(); + rect_node.base.name = "Test Rectangle".to_string(); + rect_node.transform = AffineTransform::new(300.0, 50.0, 0.0); + rect_node.size = Size { + width: 200.0, + height: 100.0, + }; + rect_node.corner_radius = RectangularCornerRadius::all(10.0); + rect_node.fill = Paint::Solid(SolidPaint { + color: Color(255, 0, 0, 255), // Red fill + opacity: 1.0, + }); + rect_node.stroke_width = 2.0; + rect_node.effect = Some(FilterEffect::DropShadow(FeDropShadow { + dx: 4.0, + dy: 4.0, + blur: 8.0, + color: Color(0, 0, 0, 77), + })); + + // Create a test ellipse node with radial gradient and a visible stroke + let mut ellipse_node = nf.create_ellipse_node(); + ellipse_node.base.name = "Test Ellipse".to_string(); + ellipse_node.blend_mode = BlendMode::Multiply; + ellipse_node.transform = AffineTransform::new(550.0, 50.0, 0.0); + ellipse_node.size = Size { + width: 200.0, + height: 200.0, + }; + ellipse_node.fill = Paint::RadialGradient(RadialGradientPaint { + transform: AffineTransform::identity(), + stops: vec![ + GradientStop { + offset: 0.0, + color: Color(0, 255, 0, 255), // Green + }, + GradientStop { + offset: 0.5, + color: Color(255, 255, 0, 255), // Yellow + }, + GradientStop { + offset: 1.0, + color: Color(255, 0, 255, 255), // Magenta + }, + ], + opacity: 1.0, + }); + ellipse_node.stroke_width = 6.0; + + // Create a test polygon node (pentagon) + let pentagon_points = (0..5) + .map(|i| { + let angle = std::f32::consts::PI * 2.0 * (i as f32) / 5.0 - std::f32::consts::FRAC_PI_2; + let radius = 100.0; + let x = radius * angle.cos(); + let y = radius * angle.sin(); + Point { x, y } + }) + .collect::>(); + + let mut polygon_node = nf.create_polygon_node(); + polygon_node.base.name = "Test Polygon".to_string(); + polygon_node.blend_mode = BlendMode::Screen; + polygon_node.transform = AffineTransform::new(800.0, 50.0, 0.0); + polygon_node.points = pentagon_points; + polygon_node.fill = Paint::Solid(SolidPaint { + color: Color(255, 200, 0, 255), // Orange fill + opacity: 1.0, + }); + polygon_node.stroke = Paint::Solid(SolidPaint { + color: Color(0, 0, 0, 255), // Black stroke + opacity: 1.0, + }); + polygon_node.stroke_width = 5.0; + + // Create a test regular polygon node (hexagon) + let mut regular_polygon_node = nf.create_regular_polygon_node(); + regular_polygon_node.base.name = "Test Regular Polygon".to_string(); + regular_polygon_node.blend_mode = BlendMode::Overlay; + regular_polygon_node.transform = AffineTransform::new(50.0, 300.0, 0.0); + regular_polygon_node.size = Size { + width: 200.0, + height: 200.0, + }; + regular_polygon_node.point_count = 6; // hexagon + regular_polygon_node.fill = Paint::Solid(SolidPaint { + color: Color(0, 200, 255, 255), // Cyan fill + opacity: 1.0, + }); + regular_polygon_node.stroke_width = 4.0; + regular_polygon_node.opacity = 0.5; + + // Create a test text span node + let mut text_span_node = nf.create_text_span_node(); + text_span_node.base.name = "Test Text".to_string(); + text_span_node.transform = AffineTransform::new(300.0, 300.0, 0.0); + text_span_node.size = Size { + width: 300.0, + height: 200.0, + }; + text_span_node.text = "Grida Canvas SKIA Bindings Backend".to_string(); + text_span_node.text_style = TextStyle { + text_decoration: TextDecoration::LineThrough, + font_family: font_caveat_family.clone(), + font_size: 32.0, + font_weight: FontWeight::new(900), + italic: false, + letter_spacing: None, + line_height: None, + text_transform: TextTransform::None, + }; + text_span_node.text_align = TextAlign::Center; + text_span_node.text_align_vertical = TextAlignVertical::Center; + text_span_node.stroke = Some(Paint::Solid(SolidPaint { + color: Color(0, 0, 0, 255), // Black stroke + opacity: 1.0, + })); + text_span_node.stroke_width = Some(4.0); + + // Create a test path node + let mut path_node = nf.create_path_node(); + path_node.base.name = "Test Path".to_string(); + path_node.transform = AffineTransform::new(550.0, 300.0, 0.0); + path_node.data = "M50 150H0v-50h50v50ZM150 150h-50v-50h50v50ZM100 100H50V50h50v50ZM50 50H0V0h50v50ZM150 50h-50V0h50v50Z".to_string(); + path_node.stroke = Paint::Solid(SolidPaint { + color: Color(255, 0, 0, 255), // Red stroke + opacity: 1.0, + }); + path_node.stroke_width = 4.0; + + // Create a test line node with solid color + let mut line_node = nf.create_line_node(); + line_node.base.name = "Test Line".to_string(); + line_node.opacity = 0.8; + line_node.transform = AffineTransform::new(800.0, 300.0, 0.0); + line_node.size = Size { + width: 200.0, + height: 0.0, // ignored + }; + line_node.stroke = Paint::Solid(SolidPaint { + color: Color(0, 255, 0, 255), // Green color + opacity: 1.0, + }); + line_node.stroke_width = 4.0; + + // Create a group node for the shapes (rectangle, ellipse, polygon) + let mut shapes_group_node = nf.create_group_node(); + shapes_group_node.base.name = "Shapes Group".to_string(); + shapes_group_node.transform = AffineTransform::new(0.0, 0.0, 0.0); + + // Create a root container node containing the shapes group, text, and line + let mut root_container_node = nf.create_container_node(); + root_container_node.size = Size { + width: 1080.0, + height: 1080.0, + }; + root_container_node.base.name = "Root Container".to_string(); + + // Create a node map and add all nodes + let mut repository = NodeRepository::new(); + + // First, collect all the IDs we'll need + let rect_id = rect_node.base.id.clone(); + let ellipse_id = ellipse_node.base.id.clone(); + let polygon_id = polygon_node.base.id.clone(); + let regular_polygon_id = regular_polygon_node.base.id.clone(); + let text_span_id = text_span_node.base.id.clone(); + let line_id = line_node.base.id.clone(); + let image_id = image_node.base.id.clone(); + let path_id = path_node.base.id.clone(); + + // Now add all nodes to the map + repository.insert(Node::Rectangle(rect_node)); + repository.insert(Node::Ellipse(ellipse_node)); + repository.insert(Node::Polygon(polygon_node)); + repository.insert(Node::RegularPolygon(regular_polygon_node)); + repository.insert(Node::TextSpan(text_span_node)); + repository.insert(Node::Line(line_node)); + repository.insert(Node::Image(image_node)); + repository.insert(Node::Path(path_node)); + + // Now set up the shapes group with the IDs we collected + shapes_group_node.children = vec![rect_id, ellipse_id, polygon_id, regular_polygon_id]; + let shapes_group_id = shapes_group_node.base.id.clone(); + repository.insert(Node::Group(shapes_group_node)); + + // Finally set up the root container with all IDs + root_container_node.children = vec![shapes_group_id, text_span_id, line_id, path_id, image_id]; + let root_container_id = root_container_node.base.id.clone(); + repository.insert(Node::Container(root_container_node)); + + Scene { + id: "scene".to_string(), + name: "Demo".to_string(), + transform: AffineTransform::identity(), + children: vec![root_container_id], + nodes: repository, + background_color: Some(Color(250, 250, 250, 255)), + } +} + +#[tokio::main] +async fn main() { + let scene = demo_basic().await; + + window::run_demo_window(scene).await; +} diff --git a/crates/grida-canvas/examples/booleans.rs b/crates/grida-canvas/examples/booleans.rs new file mode 100644 index 0000000000..33a578f369 --- /dev/null +++ b/crates/grida-canvas/examples/booleans.rs @@ -0,0 +1,375 @@ +use cg::node::factory::NodeFactory; +use cg::node::repository::NodeRepository; +use cg::node::schema::*; +use cg::window; +use math2::transform::AffineTransform; + +async fn demo_booleans() -> Scene { + let nf = NodeFactory::new(); + let mut repository = NodeRepository::new(); + + // Create a root container node + let mut root_container_node = nf.create_container_node(); + root_container_node.base.name = "Root Container".to_string(); + root_container_node.size = Size { + width: 1080.0, + height: 1080.0, + }; + + let mut all_shape_ids = Vec::new(); + let spacing = 200.0; + let start_x = 100.0; + let base_size = 100.0; + + // Example 1: Rectangle and Circle Union + { + let y_offset = 150.0; // Increased from 100.0 to give more room at top + + // Create shapes + let mut rect = nf.create_rectangle_node(); + rect.base.name = "Rectangle".to_string(); + rect.transform = AffineTransform::new(start_x, y_offset, 0.0); + rect.size = Size { + width: base_size, + height: base_size, + }; + rect.fill = Paint::Solid(SolidPaint { + color: Color(200, 200, 200, 255), + opacity: 1.0, + }); + + let mut circle = nf.create_ellipse_node(); + circle.base.name = "Circle".to_string(); + circle.transform = AffineTransform::new(start_x + spacing, y_offset, 0.0); + circle.size = Size { + width: base_size, + height: base_size, + }; + circle.fill = Paint::Solid(SolidPaint { + color: Color(200, 200, 200, 255), + opacity: 1.0, + }); + + // Add description text + let mut text = nf.create_text_span_node(); + text.base.name = "Description".to_string(); + text.transform = AffineTransform::new(start_x, y_offset - 40.0, 0.0); // Moved text up slightly + text.size = Size { + width: 500.0, // Increased width for better text display + height: 20.0, + }; + text.text = "Union (A ∪ B): Combines two shapes into one".to_string(); + text.text_style.font_size = 16.0; + text.fill = Paint::Solid(SolidPaint { + color: Color(0, 0, 0, 255), + opacity: 1.0, + }); + + // Create boolean operation + let mut bool_node = BooleanPathOperationNode { + base: BaseNode { + id: "bool_union_1".to_string(), + name: "Union Operation".to_string(), + active: true, + }, + transform: AffineTransform::new(start_x + spacing * 2.0, y_offset, 0.0), + op: BooleanPathOperation::Union, + children: vec![rect.base.id.clone(), circle.base.id.clone()], + fill: Paint::Solid(SolidPaint { + color: Color(100, 100, 200, 255), + opacity: 1.0, + }), + stroke: Some(Paint::Solid(SolidPaint { + color: Color(0, 0, 0, 255), + opacity: 1.0, + })), + stroke_width: 2.0, + stroke_align: StrokeAlign::Center, + stroke_dash_array: None, + opacity: 1.0, + blend_mode: BlendMode::Normal, + effect: None, + }; + + // Collect IDs before moving nodes + all_shape_ids.push(rect.base.id.clone()); + all_shape_ids.push(circle.base.id.clone()); + all_shape_ids.push(text.base.id.clone()); + all_shape_ids.push(bool_node.base.id.clone()); + + // Insert all nodes + repository.insert(Node::Rectangle(rect)); + repository.insert(Node::Ellipse(circle)); + repository.insert(Node::TextSpan(text)); + repository.insert(Node::BooleanOperation(bool_node)); + } + + // Example 2: Two Circles Intersection + { + let y_offset = 400.0; // Increased from 300.0 + + // Create shapes + let mut circle1 = nf.create_ellipse_node(); + circle1.base.name = "Circle 1".to_string(); + circle1.transform = AffineTransform::new(start_x, y_offset, 0.0); + circle1.size = Size { + width: base_size, + height: base_size, + }; + circle1.fill = Paint::Solid(SolidPaint { + color: Color(200, 200, 200, 255), + opacity: 1.0, + }); + + let mut circle2 = nf.create_ellipse_node(); + circle2.base.name = "Circle 2".to_string(); + circle2.transform = AffineTransform::new(start_x + 100.0, y_offset, 0.0); + circle2.size = Size { + width: base_size, + height: base_size, + }; + circle2.fill = Paint::Solid(SolidPaint { + color: Color(200, 200, 200, 255), + opacity: 1.0, + }); + + // Add description text + let mut text = nf.create_text_span_node(); + text.base.name = "Description".to_string(); + text.transform = AffineTransform::new(start_x, y_offset - 40.0, 0.0); + text.size = Size { + width: 500.0, + height: 20.0, + }; + text.text = "Intersection (A ∩ B): Shows only the overlapping area".to_string(); + text.text_style.font_size = 16.0; + text.fill = Paint::Solid(SolidPaint { + color: Color(0, 0, 0, 255), + opacity: 1.0, + }); + + // Create boolean operation + let mut bool_node = BooleanPathOperationNode { + base: BaseNode { + id: "bool_intersection_1".to_string(), + name: "Intersection Operation".to_string(), + active: true, + }, + transform: AffineTransform::new(start_x + spacing * 2.0, y_offset, 0.0), + op: BooleanPathOperation::Intersection, + children: vec![circle1.base.id.clone(), circle2.base.id.clone()], + fill: Paint::Solid(SolidPaint { + color: Color(100, 100, 200, 255), + opacity: 1.0, + }), + stroke: Some(Paint::Solid(SolidPaint { + color: Color(0, 0, 0, 255), + opacity: 1.0, + })), + stroke_width: 2.0, + stroke_align: StrokeAlign::Center, + stroke_dash_array: None, + opacity: 1.0, + blend_mode: BlendMode::Normal, + effect: None, + }; + + // Collect IDs before moving nodes + all_shape_ids.push(circle1.base.id.clone()); + all_shape_ids.push(circle2.base.id.clone()); + all_shape_ids.push(text.base.id.clone()); + all_shape_ids.push(bool_node.base.id.clone()); + + // Insert all nodes + repository.insert(Node::Ellipse(circle1)); + repository.insert(Node::Ellipse(circle2)); + repository.insert(Node::TextSpan(text)); + repository.insert(Node::BooleanOperation(bool_node)); + } + + // Example 3: Star and Rectangle Difference + { + let y_offset = 650.0; // Increased from 500.0 + + // Create shapes + let mut star = nf.create_regular_star_polygon_node(); + star.base.name = "Star".to_string(); + star.transform = AffineTransform::new(start_x, y_offset, 0.0); + star.size = Size { + width: base_size, + height: base_size, + }; + star.fill = Paint::Solid(SolidPaint { + color: Color(200, 200, 200, 255), + opacity: 1.0, + }); + + let mut rect = nf.create_rectangle_node(); + rect.base.name = "Rectangle".to_string(); + rect.transform = AffineTransform::new(start_x + spacing * 0.5, y_offset, 0.0); + rect.size = Size { + width: base_size * 0.8, + height: base_size * 0.8, + }; + rect.fill = Paint::Solid(SolidPaint { + color: Color(200, 200, 200, 255), + opacity: 1.0, + }); + + // Add description text + let mut text = nf.create_text_span_node(); + text.base.name = "Description".to_string(); + text.transform = AffineTransform::new(start_x, y_offset - 40.0, 0.0); + text.size = Size { + width: 500.0, + height: 20.0, + }; + text.text = "Difference (A - B): Removes the second shape from the first".to_string(); + text.text_style.font_size = 16.0; + text.fill = Paint::Solid(SolidPaint { + color: Color(0, 0, 0, 255), + opacity: 1.0, + }); + + // Create boolean operation + let mut bool_node = BooleanPathOperationNode { + base: BaseNode { + id: "bool_difference_1".to_string(), + name: "Difference Operation".to_string(), + active: true, + }, + transform: AffineTransform::new(start_x + spacing * 2.0, y_offset, 0.0), + op: BooleanPathOperation::Difference, + children: vec![star.base.id.clone(), rect.base.id.clone()], + fill: Paint::Solid(SolidPaint { + color: Color(100, 100, 200, 255), + opacity: 1.0, + }), + stroke: Some(Paint::Solid(SolidPaint { + color: Color(0, 0, 0, 255), + opacity: 1.0, + })), + stroke_width: 2.0, + stroke_align: StrokeAlign::Center, + stroke_dash_array: None, + opacity: 1.0, + blend_mode: BlendMode::Normal, + effect: None, + }; + + // Collect IDs before moving nodes + all_shape_ids.push(star.base.id.clone()); + all_shape_ids.push(rect.base.id.clone()); + all_shape_ids.push(text.base.id.clone()); + all_shape_ids.push(bool_node.base.id.clone()); + + // Insert all nodes + repository.insert(Node::RegularStarPolygon(star)); + repository.insert(Node::Rectangle(rect)); + repository.insert(Node::TextSpan(text)); + repository.insert(Node::BooleanOperation(bool_node)); + } + + // Example 4: Two Squares XOR + { + let y_offset = 900.0; // Increased from 700.0 + + // Create shapes + let mut square1 = nf.create_rectangle_node(); + square1.base.name = "Square 1".to_string(); + square1.transform = AffineTransform::new(start_x, y_offset, 0.0); + square1.size = Size { + width: base_size, + height: base_size, + }; + square1.fill = Paint::Solid(SolidPaint { + color: Color(200, 200, 200, 255), + opacity: 1.0, + }); + + let mut square2 = nf.create_rectangle_node(); + square2.base.name = "Square 2".to_string(); + square2.transform = AffineTransform::new(start_x + spacing * 0.5, y_offset, 0.0); + square2.size = Size { + width: base_size, + height: base_size, + }; + square2.fill = Paint::Solid(SolidPaint { + color: Color(200, 200, 200, 255), + opacity: 1.0, + }); + + // Add description text + let mut text = nf.create_text_span_node(); + text.base.name = "Description".to_string(); + text.transform = AffineTransform::new(start_x, y_offset - 40.0, 0.0); + text.size = Size { + width: 500.0, + height: 20.0, + }; + text.text = "XOR (A ⊕ B): Shows areas that don't overlap".to_string(); + text.text_style.font_size = 16.0; + text.fill = Paint::Solid(SolidPaint { + color: Color(0, 0, 0, 255), + opacity: 1.0, + }); + + // Create boolean operation + let mut bool_node = BooleanPathOperationNode { + base: BaseNode { + id: "bool_xor_1".to_string(), + name: "XOR Operation".to_string(), + active: true, + }, + transform: AffineTransform::new(start_x + spacing * 2.0, y_offset, 0.0), + op: BooleanPathOperation::Xor, + children: vec![square1.base.id.clone(), square2.base.id.clone()], + fill: Paint::Solid(SolidPaint { + color: Color(100, 100, 200, 255), + opacity: 1.0, + }), + stroke: Some(Paint::Solid(SolidPaint { + color: Color(0, 0, 0, 255), + opacity: 1.0, + })), + stroke_width: 2.0, + stroke_align: StrokeAlign::Center, + stroke_dash_array: None, + opacity: 1.0, + blend_mode: BlendMode::Normal, + effect: None, + }; + + // Collect IDs before moving nodes + all_shape_ids.push(square1.base.id.clone()); + all_shape_ids.push(square2.base.id.clone()); + all_shape_ids.push(text.base.id.clone()); + all_shape_ids.push(bool_node.base.id.clone()); + + // Insert all nodes + repository.insert(Node::Rectangle(square1)); + repository.insert(Node::Rectangle(square2)); + repository.insert(Node::TextSpan(text)); + repository.insert(Node::BooleanOperation(bool_node)); + } + + // Set up the root container + root_container_node.children.extend(all_shape_ids); + let root_container_id = root_container_node.base.id.clone(); + repository.insert(Node::Container(root_container_node)); + + Scene { + id: "scene".to_string(), + name: "Boolean Operations Demo".to_string(), + transform: AffineTransform::identity(), + children: vec![root_container_id], + nodes: repository, + background_color: Some(Color(250, 250, 250, 255)), + } +} + +#[tokio::main] +async fn main() { + let scene = demo_booleans().await; + window::run_demo_window(scene).await; +} diff --git a/crates/grida-canvas/examples/cache_image.rs b/crates/grida-canvas/examples/cache_image.rs new file mode 100644 index 0000000000..e77132427d --- /dev/null +++ b/crates/grida-canvas/examples/cache_image.rs @@ -0,0 +1,314 @@ +//! This example demonstrates the performance benefits of image caching in Skia across different +//! device pixel ratios (DPR) and canvas sizes. It benchmarks the following scenarios: +//! +//! 1. Drawing Performance: +//! - Compares cached vs uncached drawing performance +//! - Measures performance across various device sizes and DPRs +//! - Shows how caching affects rendering speed +//! +//! 2. DPR Handling: +//! - Demonstrates proper handling of device pixel ratios +//! - Creates surfaces at logical size and applies DPR scaling during drawing +//! - Shows how caching works with high-DPI displays +//! +//! 3. Device Coverage: +//! - Tests various device sizes from mobile to desktop +//! - Includes different DPR values (1.0, 2.0, 3.0) +//! - Covers common display resolutions and device types +//! +//! The benchmark draws a complex pattern of circles and measures: +//! - Average, minimum, and maximum drawing times +//! - Performance improvement ratio between cached and uncached drawing +//! - Impact of DPR scaling on performance +//! +//! This is particularly useful for understanding: +//! - When to use image caching +//! - How DPR affects rendering performance +//! - Performance characteristics across different device types + +use skia_safe::*; +use std::time::{Duration, Instant}; + +struct CanvasSpec { + name: String, + logical: (i32, i32), + dpr: f32, + physical: (i32, i32), +} + +fn get_canvas_specs() -> Vec { + vec![ + CanvasSpec { + name: "iPhone SE".to_string(), + logical: (375, 667), + dpr: 2.0, + physical: (750, 1334), + }, + CanvasSpec { + name: "iPhone 14 Pro".to_string(), + logical: (430, 932), + dpr: 3.0, + physical: (1290, 2796), + }, + CanvasSpec { + name: "iPad Pro 11\"".to_string(), + logical: (834, 1194), + dpr: 2.0, + physical: (1668, 2388), + }, + CanvasSpec { + name: "MacBook Air 13\"".to_string(), + logical: (1440, 900), + dpr: 2.0, + physical: (2880, 1800), + }, + CanvasSpec { + name: "MacBook Pro 16\"".to_string(), + logical: (1728, 1117), + dpr: 2.0, + physical: (3456, 2234), + }, + CanvasSpec { + name: "Studio Display".to_string(), + logical: (2560, 1440), + dpr: 2.0, + physical: (5120, 2880), + }, + CanvasSpec { + name: "4K Monitor".to_string(), + logical: (1920, 1080), + dpr: 2.0, + physical: (3840, 2160), + }, + CanvasSpec { + name: "5K Monitor".to_string(), + logical: (2560, 1440), + dpr: 2.0, + physical: (5120, 2880), + }, + CanvasSpec { + name: "1080p Display".to_string(), + logical: (1920, 1080), + dpr: 1.0, + physical: (1920, 1080), + }, + CanvasSpec { + name: "Small Canvas".to_string(), + logical: (512, 512), + dpr: 1.0, + physical: (512, 512), + }, + ] +} + +fn draw_complex(canvas: &Canvas) { + let width = canvas.image_info().width() as f32; + let height = canvas.image_info().height() as f32; + + // Calculate grid size based on canvas dimensions + let grid_size = (width * height).sqrt() / 20.0; // Adjust divisor to control density + let cols = (width / grid_size).ceil() as i32; + let rows = (height / grid_size).ceil() as i32; + let circle_radius = grid_size * 0.4; // Circle size relative to grid cell + + // Create a gradient shader + let colors = [ + Color::from_argb(0xFF, 0xFF, 0x00, 0x00), // Red + Color::from_argb(0xFF, 0x00, 0xFF, 0x00), // Green + Color::from_argb(0xFF, 0x00, 0x00, 0xFF), // Blue + ]; + let positions = [0.0, 0.5, 1.0]; + let shader = Shader::linear_gradient( + (Point::new(0.0, 0.0), Point::new(width, height)), + &colors[..], + Some(&positions[..]), + TileMode::Clamp, + None, + None, + ) + .unwrap(); + + // Create multiple layers with different effects + for layer in 0..3 { + let mut paint = Paint::default(); + paint.set_anti_alias(true); + paint.set_shader(shader.clone()); + + // Apply different blend modes for each layer + let blend_mode = match layer { + 0 => BlendMode::Multiply, + 1 => BlendMode::Screen, + 2 => BlendMode::Overlay, + _ => unreachable!(), + }; + paint.set_blend_mode(blend_mode); + + // Draw circles with blur effect + for row in 0..rows { + for col in 0..cols { + let x = col as f32 * grid_size + grid_size / 2.0; + let y = row as f32 * grid_size + grid_size / 2.0; + + // Apply different blur radius for each layer + let blur_radius = match layer { + 0 => grid_size * 0.1, // Scale blur with grid size + 1 => grid_size * 0.15, + 2 => grid_size * 0.2, + _ => unreachable!(), + }; + + // Create a temporary surface for the blur effect + let temp_size = (circle_radius * 2.0) as i32; + let mut temp_surface = + Surface::new_raster_n32_premul((temp_size, temp_size)).unwrap(); + let temp_canvas = temp_surface.canvas(); + + // Draw the circle on the temporary surface + temp_canvas.draw_circle( + (temp_size as f32 / 2.0, temp_size as f32 / 2.0), + circle_radius, + &paint, + ); + + // Apply blur to the temporary surface + let image = temp_surface.image_snapshot(); + let mut blur_paint = Paint::default(); + blur_paint.set_image_filter(image_filters::blur( + (blur_radius, blur_radius), + None, + None, + None, + )); + + // Draw the blurred circle + canvas.draw_image( + image, + (x - circle_radius, y - circle_radius), + Some(&blur_paint), + ); + } + } + } + + // Add a final overlay with a radial gradient + let mut overlay_paint = Paint::default(); + let radial_colors = [ + Color::from_argb(0x40, 0xFF, 0xFF, 0xFF), // Semi-transparent white + Color::from_argb(0x00, 0xFF, 0xFF, 0xFF), // Transparent + ]; + let radial_positions = [0.0, 1.0]; + let radial_shader = Shader::radial_gradient( + Point::new(width / 2.0, height / 2.0), + width.min(height) / 2.0, + &radial_colors[..], + Some(&radial_positions[..]), + TileMode::Clamp, + None, + None, + ) + .unwrap(); + overlay_paint.set_shader(radial_shader); + overlay_paint.set_blend_mode(BlendMode::SoftLight); + canvas.draw_rect(Rect::new(0.0, 0.0, width, height), &overlay_paint); +} + +fn make_cached_image(surface: &mut Surface, dpr: f32) -> Image { + let canvas = surface.canvas(); + canvas.clear(Color::WHITE); + + // Apply DPR scaling + canvas.save(); + canvas.scale((dpr, dpr)); + draw_complex(canvas); + canvas.restore(); + + // Create a new surface with the same size for the cached image + let mut cached_surface = + Surface::new_raster_n32_premul((surface.width(), surface.height())).unwrap(); + let cached_canvas = cached_surface.canvas(); + cached_canvas.draw_image(surface.image_snapshot(), (0, 0), None); + cached_surface.image_snapshot() +} + +fn run_benchmark(use_cache: bool, surface: &mut Surface, cached: &Image, dpr: f32) -> Duration { + let canvas = surface.canvas(); + canvas.clear(Color::WHITE); + + let start = Instant::now(); + if use_cache { + let mut paint = Paint::default(); + canvas.draw_image(cached, (0, 0), Some(&paint)); + } else { + // Apply DPR scaling for uncached drawing + canvas.save(); + canvas.scale((dpr, dpr)); + draw_complex(canvas); + canvas.restore(); + } + start.elapsed() +} + +fn run_benchmark_for_size(spec: &CanvasSpec) { + println!("\nRunning benchmark for {}:", spec.name); + println!("Logical size: {}x{}", spec.logical.0, spec.logical.1); + println!("Physical size: {}x{}", spec.physical.0, spec.physical.1); + println!("DPR: {}", spec.dpr); + + // Create surface at logical size + let mut surface = Surface::new_raster_n32_premul(spec.logical).unwrap(); + let cached = make_cached_image(&mut surface, spec.dpr); + + const FRAMES: usize = 100; + let mut cached_times = Vec::with_capacity(FRAMES); + let mut uncached_times = Vec::with_capacity(FRAMES); + + // Warm up + for _ in 0..10 { + run_benchmark(true, &mut surface, &cached, spec.dpr); + run_benchmark(false, &mut surface, &cached, spec.dpr); + } + + // Collect measurements + for _ in 0..FRAMES { + cached_times.push(run_benchmark(true, &mut surface, &cached, spec.dpr)); + uncached_times.push(run_benchmark(false, &mut surface, &cached, spec.dpr)); + } + + // Calculate statistics + let cached_avg: Duration = cached_times.iter().sum::() / FRAMES as u32; + let uncached_avg: Duration = uncached_times.iter().sum::() / FRAMES as u32; + + let cached_min = cached_times.iter().min().unwrap(); + let cached_max = cached_times.iter().max().unwrap(); + let uncached_min = uncached_times.iter().min().unwrap(); + let uncached_max = uncached_times.iter().max().unwrap(); + + println!("\nPerformance Results:"); + println!("-------------------"); + println!("Cached drawing:"); + println!(" Average: {:?}", cached_avg); + println!(" Min: {:?}", cached_min); + println!(" Max: {:?}", cached_max); + println!("\nUncached drawing:"); + println!(" Average: {:?}", uncached_avg); + println!(" Min: {:?}", uncached_min); + println!(" Max: {:?}", uncached_max); + println!("\nPerformance improvement:"); + println!( + " Speedup: {:.2}x", + uncached_avg.as_secs_f64() / cached_avg.as_secs_f64() + ); +} + +fn main() { + let specs = get_canvas_specs(); + + println!( + "Starting benchmarks for {} different canvas sizes...", + specs.len() + ); + + for spec in specs { + run_benchmark_for_size(&spec); + } +} diff --git a/crates/grida-canvas/examples/cache_picture.rs b/crates/grida-canvas/examples/cache_picture.rs new file mode 100644 index 0000000000..7b4dd76cce --- /dev/null +++ b/crates/grida-canvas/examples/cache_picture.rs @@ -0,0 +1,338 @@ +use cg::window::scheduler::FrameScheduler; +use gl_rs as gl; +use glutin::{ + config::{ConfigTemplateBuilder, GlConfig}, + context::{ContextApi, ContextAttributesBuilder, PossiblyCurrentContext}, + display::{GetGlDisplay, GlDisplay}, + prelude::{GlSurface, NotCurrentGlContext}, + surface::{Surface as GlutinSurface, SurfaceAttributesBuilder, SwapInterval, WindowSurface}, +}; +use glutin_winit::DisplayBuilder; +use raw_window_handle::HasRawWindowHandle; +use skia_safe::{Canvas, Color, Paint, Picture, PictureRecorder, Rect, Surface}; +use std::{ffi::CString, num::NonZeroU32, time::Instant}; +use winit::{ + event::{Event, WindowEvent}, + event_loop::EventLoop, +}; + +struct CachedScene { + picture: Picture, +} + +impl CachedScene { + fn new(width: f32, height: f32) -> Self { + // Create a recorder to capture the static scene + let mut recorder = PictureRecorder::new(); + let bounds = Rect::new(0.0, 0.0, width, height); + let canvas = recorder.begin_recording(bounds, None); + + // Draw some static content + Self::draw_static_content(canvas); + + // End recording and create the picture + let picture = recorder.finish_recording_as_picture(None).unwrap(); + + Self { picture } + } + + fn draw_static_content(canvas: &Canvas) { + println!("[cache] Drawing static content (should only appear once)"); + // Draw a grid of rectangles + for i in 0..10 { + for j in 0..10 { + let mut paint = Paint::default(); + paint.set_color(Color::from_argb(255, 100, 100, 100)); + + let x = i as f32 * 100.0; + let y = j as f32 * 100.0; + let rect = Rect::new(x, y, x + 50.0, y + 50.0); + canvas.draw_rect(rect, &paint); + } + } + } + + fn render(&self, canvas: &Canvas, camera_x: f32, camera_y: f32, zoom: f32) { + canvas.save(); + + // Apply camera transform + canvas.translate((camera_x, camera_y)); + canvas.scale((zoom, zoom)); + + // Draw the cached picture + canvas.draw_picture(&self.picture, None, None); + + canvas.restore(); + } +} + +fn init_window( + width: i32, + height: i32, +) -> ( + *mut Surface, + EventLoop<()>, + winit::window::Window, + GlutinSurface, + PossiblyCurrentContext, + glutin::config::Config, + skia_safe::gpu::gl::FramebufferInfo, + skia_safe::gpu::DirectContext, + f64, +) { + let el = EventLoop::new().expect("Failed to create event loop"); + let window_attributes = winit::window::WindowAttributes::default() + .with_title("Skia Picture Cache Demo") + .with_inner_size(winit::dpi::LogicalSize::new(width, height)); + let template = ConfigTemplateBuilder::new() + .with_alpha_size(8) + .with_transparency(true); + let display_builder = DisplayBuilder::new().with_window_attributes(window_attributes.into()); + let (window, gl_config) = display_builder + .build(&el, template, |configs| { + configs + .reduce(|accum, config| { + let transparency_check = config.supports_transparency().unwrap_or(false) + & !accum.supports_transparency().unwrap_or(false); + if transparency_check || config.num_samples() < accum.num_samples() { + config + } else { + accum + } + }) + .unwrap() + }) + .unwrap(); + let window = window.expect("Could not create window with OpenGL context"); + let raw_window_handle = window + .raw_window_handle() + .expect("Failed to get window handle"); + let scale_factor = window.scale_factor(); + let context_attributes = ContextAttributesBuilder::new().build(Some(raw_window_handle)); + let fallback_context_attributes = ContextAttributesBuilder::new() + .with_context_api(ContextApi::Gles(None)) + .build(Some(raw_window_handle)); + let not_current_gl_context = unsafe { + gl_config + .display() + .create_context(&gl_config, &context_attributes) + .unwrap_or_else(|_| { + gl_config + .display() + .create_context(&gl_config, &fallback_context_attributes) + .expect("failed to create context") + }) + }; + let (width, height): (u32, u32) = window.inner_size().into(); + let attrs = SurfaceAttributesBuilder::::new().build( + raw_window_handle, + NonZeroU32::new(width).unwrap(), + NonZeroU32::new(height).unwrap(), + ); + let gl_surface = unsafe { + gl_config + .display() + .create_window_surface(&gl_config, &attrs) + .expect("Could not create gl window surface") + }; + let gl_context = not_current_gl_context + .make_current(&gl_surface) + .expect("Could not make GL context current"); + + gl::load_with(|s| { + gl_config + .display() + .get_proc_address(CString::new(s).unwrap().as_c_str()) + }); + + // Enable VSync + gl_surface + .set_swap_interval(&gl_context, SwapInterval::Wait(NonZeroU32::new(1).unwrap())) + .expect("Failed to set swap interval"); + + let interface = skia_safe::gpu::gl::Interface::new_load_with(|name| { + if name == "eglGetCurrentDisplay" { + return std::ptr::null(); + } + gl_config + .display() + .get_proc_address(CString::new(name).unwrap().as_c_str()) + }) + .expect("Could not create interface"); + let mut gr_context = skia_safe::gpu::direct_contexts::make_gl(interface, None) + .expect("Could not create direct context"); + let fb_info = { + let mut fboid: gl::types::GLint = 0; + unsafe { gl::GetIntegerv(gl::FRAMEBUFFER_BINDING, &mut fboid) }; + skia_safe::gpu::gl::FramebufferInfo { + fboid: fboid.try_into().unwrap(), + format: skia_safe::gpu::gl::Format::RGBA8.into(), + ..Default::default() + } + }; + let backend_render_target = skia_safe::gpu::backend_render_targets::make_gl( + (width as i32, height as i32), + gl_config.num_samples() as usize, + gl_config.stencil_size() as usize, + fb_info, + ); + let surface = skia_safe::gpu::surfaces::wrap_backend_render_target( + &mut gr_context, + &backend_render_target, + skia_safe::gpu::SurfaceOrigin::BottomLeft, + skia_safe::ColorType::RGBA8888, + None, + None, + ) + .expect("Could not create skia surface"); + ( + Box::into_raw(Box::new(surface)), + el, + window, + gl_surface, + gl_context, + gl_config, + fb_info, + gr_context, + scale_factor, + ) +} + +fn main() { + let ( + surface_ptr, + el, + window, + mut gl_surface, + gl_context, + gl_config, + fb_info, + mut gr_context, + scale_factor, + ) = init_window(800, 600); + + // SAFETY: We own the surface pointer + let surface = unsafe { &mut *surface_ptr }; + let size = window.inner_size(); + let scene = CachedScene::new(size.width as f32, size.height as f32); + let mut camera_x = 0.0; + let mut camera_y = 0.0; + let mut zoom = 1.0; + let start_time = Instant::now(); + let mut frame_count = 0; + let mut last_fps_time = Instant::now(); + let mut last_frame_time = Instant::now(); + + // Create frame scheduler with 120 FPS target and 144 FPS max + let mut scheduler = FrameScheduler::new(120).with_max_fps(144); + + // Enable pre-present notification for better frame timing + window.pre_present_notify(); + + el.run(move |event, elwt| { + match event { + Event::WindowEvent { + event: WindowEvent::CloseRequested, + .. + } => { + elwt.exit(); + } + Event::WindowEvent { + event: WindowEvent::Resized(size), + .. + } => { + // Recreate GL surface and Skia surface on resize + let attrs = SurfaceAttributesBuilder::::new().build( + window + .raw_window_handle() + .expect("Failed to get window handle"), + NonZeroU32::new(size.width).unwrap(), + NonZeroU32::new(size.height).unwrap(), + ); + gl_surface = unsafe { + gl_config + .display() + .create_window_surface(&gl_config, &attrs) + .expect("Could not create gl window surface") + }; + let backend_render_target = skia_safe::gpu::backend_render_targets::make_gl( + (size.width as i32, size.height as i32), + gl_config.num_samples() as usize, + gl_config.stencil_size() as usize, + fb_info, + ); + let new_surface = skia_safe::gpu::surfaces::wrap_backend_render_target( + &mut gr_context, + &backend_render_target, + skia_safe::gpu::SurfaceOrigin::BottomLeft, + skia_safe::ColorType::RGBA8888, + None, + None, + ) + .expect("Could not create skia surface"); + unsafe { _ = Box::from_raw(surface_ptr) }; + let new_surface_ptr = Box::into_raw(Box::new(new_surface)); + // SAFETY: update surface pointer + let surface = unsafe { &mut *new_surface_ptr }; + // Optionally, re-record the scene if you want to match new size + // scene = CachedScene::new(size.width as f32, size.height as f32); + } + Event::WindowEvent { + event: WindowEvent::RedrawRequested, + .. + } => { + let now = Instant::now(); + let elapsed = now.duration_since(start_time).as_secs_f32(); + let angle = elapsed * 2.0; + camera_x = angle.cos() * 100.0; + camera_y = angle.sin() * 100.0; + + // Add zoom animation + let zoom_angle = elapsed * 1.0; // Slower zoom cycle + zoom = 1.0 + zoom_angle.sin() * 0.5; // Oscillate between 0.5 and 1.5 + + // Clear and render + let canvas = surface.canvas(); + canvas.clear(Color::from_argb(255, 255, 255, 255)); + scene.render(canvas, camera_x, camera_y, zoom); + + // Flush GPU commands + if let Some(mut gr_ctx) = surface.recording_context() { + if let Some(mut direct_ctx) = gr_ctx.as_direct_context() { + direct_ctx.flush_and_submit(); + // Wait for GPU to finish using flush_and_submit + direct_ctx.flush_and_submit(); + } + } + + // Swap buffers + if let Err(e) = gl_surface.swap_buffers(&gl_context) { + eprintln!("Error swapping buffers: {:?}", e); + } + + // Frame timing and pacing + let frame_time = now.duration_since(last_frame_time); + scheduler.sleep_to_maintain_fps(); + last_frame_time = now; + + // FPS calculation + frame_count += 1; + if now.duration_since(last_fps_time).as_secs_f32() >= 1.0 { + println!( + "FPS: {} (Target: {:.1}, Frame Time: {:.2}ms)", + frame_count, + scheduler.average_fps(), + frame_time.as_secs_f32() * 1000.0 + ); + frame_count = 0; + last_fps_time = now; + } + + // Request next frame + window.request_redraw(); + } + _ => {} + } + }) + .unwrap(); +} diff --git a/crates/grida-canvas/examples/cache_text_picture.rs b/crates/grida-canvas/examples/cache_text_picture.rs new file mode 100644 index 0000000000..c540f077d3 --- /dev/null +++ b/crates/grida-canvas/examples/cache_text_picture.rs @@ -0,0 +1,124 @@ +use skia_safe::textlayout::{ + FontCollection, Paragraph, ParagraphBuilder, ParagraphStyle, TextStyle, +}; +use skia_safe::{Color, Picture, PictureRecorder, Point, Rect, Surface}; +use std::time::{Duration, Instant}; + +fn make_paragraph(fc: &FontCollection, text: &str, width: f32, font_size: f32) -> Paragraph { + let mut ps = ParagraphStyle::new(); + ps.set_text_align(skia_safe::textlayout::TextAlign::Left); + let mut builder = ParagraphBuilder::new(&ps, fc); + let mut ts = TextStyle::new(); + ts.set_font_size(font_size); + builder.push_style(&ts); + builder.add_text(text); + let mut paragraph = builder.build(); + paragraph.layout(width); + paragraph +} + +fn make_picture(fc: &FontCollection, texts: &[(&str, f32)], width: f32, height: f32) -> Picture { + let mut recorder = PictureRecorder::new(); + let canvas = recorder.begin_recording(Rect::from_xywh(0.0, 0.0, width, height), None); + + let mut y_offset = 0.0; + for (text, font_size) in texts { + let para = make_paragraph(fc, text, width, *font_size); + para.paint(canvas, Point::new(0.0, y_offset)); + y_offset += para.height() + 20.0; // Add some spacing between paragraphs + } + + recorder.finish_recording_as_picture(None).unwrap() +} + +fn run( + use_cache: bool, + surface: &mut Surface, + fc: &FontCollection, + pic: &Picture, + texts: &[(&str, f32)], +) -> Duration { + let width = surface.width() as f32; + let canvas = surface.canvas(); + canvas.clear(Color::WHITE); + let start = Instant::now(); + + if use_cache { + canvas.draw_picture(pic, None, None); + } else { + let mut y_offset = 0.0; + for (text, font_size) in texts { + let para = make_paragraph(fc, text, width, *font_size); + para.paint(canvas, Point::new(0.0, y_offset)); + y_offset += para.height() + 20.0; + } + } + + start.elapsed() +} + +fn main() { + let fc = FontCollection::new(); + + // Create a more complex text content with multiple paragraphs + let text1 = "The quick brown fox jumps over the lazy dog. ".repeat(20); + let text2 = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. ".repeat(15); + let text3 = "Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. ".repeat(10); + let text4 = "Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris. ".repeat(8); + + let texts: Vec<(&str, f32)> = vec![ + (text1.as_str(), 16.0), + (text2.as_str(), 20.0), + (text3.as_str(), 24.0), + (text4.as_str(), 18.0), + ]; + + let width = 1200; + let height = 2000; + let mut surface = Surface::new_raster_n32_premul((width, height)).unwrap(); + let pic = make_picture(&fc, &texts, width as f32, height as f32); + + const FRAMES: usize = 500; // Increased number of frames for better statistics + let mut cached_times = Vec::with_capacity(FRAMES); + let mut uncached_times = Vec::with_capacity(FRAMES); + + // Warmup phase + for _ in 0..20 { + // Increased warmup iterations + run(true, &mut surface, &fc, &pic, &texts); + run(false, &mut surface, &fc, &pic, &texts); + } + + // Benchmark phase + for _ in 0..FRAMES { + cached_times.push(run(true, &mut surface, &fc, &pic, &texts)); + uncached_times.push(run(false, &mut surface, &fc, &pic, &texts)); + } + + // Calculate statistics + let avg_cached: Duration = cached_times.iter().sum::() / FRAMES as u32; + let avg_uncached: Duration = uncached_times.iter().sum::() / FRAMES as u32; + + // Calculate standard deviation + let cached_std: f64 = cached_times + .iter() + .map(|&d| (d.as_nanos() as f64 - avg_cached.as_nanos() as f64).powi(2)) + .sum::() + .sqrt() + / (FRAMES as f64).sqrt(); + + let uncached_std: f64 = uncached_times + .iter() + .map(|&d| (d.as_nanos() as f64 - avg_uncached.as_nanos() as f64).powi(2)) + .sum::() + .sqrt() + / (FRAMES as f64).sqrt(); + + println!("Benchmark Results:"); + println!("Cached avg: {:?} (±{:.2}ns)", avg_cached, cached_std); + println!("Uncached avg: {:?} (±{:.2}ns)", avg_uncached, uncached_std); + println!( + "Speedup: {:.2}x", + avg_uncached.as_secs_f64() / avg_cached.as_secs_f64() + ); +} diff --git a/crates/grida-canvas/examples/camera.rs b/crates/grida-canvas/examples/camera.rs new file mode 100644 index 0000000000..968f424e18 --- /dev/null +++ b/crates/grida-canvas/examples/camera.rs @@ -0,0 +1,351 @@ +use cg::node::factory::NodeFactory; +use cg::{ + node::schema::*, + runtime::camera::Camera2D, + runtime::scene::{Backend, Renderer}, +}; +use gl_rs as gl; +use glutin::{ + config::{ConfigTemplateBuilder, GlConfig}, + context::{ContextApi, ContextAttributesBuilder, PossiblyCurrentContext}, + display::{GetGlDisplay, GlDisplay}, + prelude::{GlSurface, NotCurrentGlContext}, + surface::{Surface as GlutinSurface, SurfaceAttributesBuilder, WindowSurface}, +}; +use glutin_winit::DisplayBuilder; +use math2::transform::AffineTransform; +use raw_window_handle::HasRawWindowHandle; +use skia_safe::{Surface, gpu}; +use std::{ffi::CString, num::NonZeroU32}; +use winit::{ + event::{Event, WindowEvent}, + event_loop::EventLoop, + window::{Window, WindowAttributes}, +}; + +fn create_static_scene() -> Scene { + let mut repository = cg::node::repository::NodeRepository::new(); + let nf = NodeFactory::new(); + + // Create a grid of rectangles + let mut ids = Vec::new(); + for i in 0..10 { + for j in 0..10 { + let mut rect = nf.create_rectangle_node(); + let id = rect.base.id.clone(); + rect.base.name = format!("Rectangle {}-{}", i, j); + rect.transform = AffineTransform::new(i as f32 * 100.0, j as f32 * 100.0, 0.0); + rect.size = Size { + width: 50.0, + height: 50.0, + }; + repository.insert(Node::Rectangle(rect)); + ids.push(id); + } + } + + // Create a root group containing all rectangles + let root_group = GroupNode { + base: BaseNode { + id: "root".to_string(), + name: "Root Group".to_string(), + active: true, + }, + transform: AffineTransform::identity(), + children: ids, + opacity: 1.0, + blend_mode: BlendMode::Normal, + }; + + repository.insert(Node::Group(root_group)); + + Scene { + id: "scene".to_string(), + name: "Test Scene".to_string(), + transform: AffineTransform::identity(), + children: vec!["root".to_string()], + nodes: repository, + background_color: Some(Color(255, 255, 255, 255)), + } +} + +fn init_window( + width: i32, + height: i32, +) -> ( + *mut Surface, + EventLoop<()>, + Window, + GlutinSurface, + PossiblyCurrentContext, + glutin::config::Config, + gpu::gl::FramebufferInfo, + skia_safe::gpu::DirectContext, + f64, +) { + // Create event loop and window + let el = EventLoop::new().expect("Failed to create event loop"); + let window_attributes = WindowAttributes::default() + .with_title("Grida Canvas - Caching Demo") + .with_inner_size(winit::dpi::LogicalSize::new(width, height)); + + // Create GL config template + let template = ConfigTemplateBuilder::new() + .with_alpha_size(8) + .with_transparency(true); + + // Build display and get window + let display_builder = DisplayBuilder::new().with_window_attributes(window_attributes.into()); + let (window, gl_config) = display_builder + .build(&el, template, |configs| { + configs + .reduce(|accum, config| { + let transparency_check = config.supports_transparency().unwrap_or(false) + & !accum.supports_transparency().unwrap_or(false); + + if transparency_check || config.num_samples() < accum.num_samples() { + config + } else { + accum + } + }) + .unwrap() + }) + .unwrap(); + + let window = window.expect("Could not create window with OpenGL context"); + let raw_window_handle = window + .raw_window_handle() + .expect("Failed to get window handle"); + let scale_factor = window.scale_factor(); + + // Create context + let context_attributes = ContextAttributesBuilder::new().build(Some(raw_window_handle)); + let fallback_context_attributes = ContextAttributesBuilder::new() + .with_context_api(ContextApi::Gles(None)) + .build(Some(raw_window_handle)); + + let not_current_gl_context = unsafe { + gl_config + .display() + .create_context(&gl_config, &context_attributes) + .unwrap_or_else(|_| { + gl_config + .display() + .create_context(&gl_config, &fallback_context_attributes) + .expect("failed to create context") + }) + }; + + let (width, height): (u32, u32) = window.inner_size().into(); + let attrs = SurfaceAttributesBuilder::::new().build( + raw_window_handle, + NonZeroU32::new(width).unwrap(), + NonZeroU32::new(height).unwrap(), + ); + + let gl_surface = unsafe { + gl_config + .display() + .create_window_surface(&gl_config, &attrs) + .expect("Could not create gl window surface") + }; + + let gl_context = not_current_gl_context + .make_current(&gl_surface) + .expect("Could not make GL context current"); + + // Initialize GL + gl::load_with(|s| { + gl_config + .display() + .get_proc_address(CString::new(s).unwrap().as_c_str()) + }); + + // Create Skia interface + let interface = skia_safe::gpu::gl::Interface::new_load_with(|name| { + if name == "eglGetCurrentDisplay" { + return std::ptr::null(); + } + gl_config + .display() + .get_proc_address(CString::new(name).unwrap().as_c_str()) + }) + .expect("Could not create interface"); + + let mut gr_context = skia_safe::gpu::direct_contexts::make_gl(interface, None) + .expect("Could not create direct context"); + + // Get framebuffer info + let fb_info = { + let mut fboid: gl::types::GLint = 0; + unsafe { gl::GetIntegerv(gl::FRAMEBUFFER_BINDING, &mut fboid) }; + gpu::gl::FramebufferInfo { + fboid: fboid.try_into().unwrap(), + format: skia_safe::gpu::gl::Format::RGBA8.into(), + ..Default::default() + } + }; + + // Create Skia surface + let backend_render_target = gpu::backend_render_targets::make_gl( + (width as i32, height as i32), + gl_config.num_samples() as usize, + gl_config.stencil_size() as usize, + fb_info, + ); + + let surface = gpu::surfaces::wrap_backend_render_target( + &mut gr_context, + &backend_render_target, + skia_safe::gpu::SurfaceOrigin::BottomLeft, + skia_safe::ColorType::RGBA8888, + None, + None, + ) + .expect("Could not create skia surface"); + + ( + Box::into_raw(Box::new(surface)), + el, + window, + gl_surface, + gl_context, + gl_config, + fb_info, + gr_context, + scale_factor, + ) +} + +fn main() { + let ( + surface_ptr, + el, + window, + gl_surface, + gl_context, + gl_config, + fb_info, + mut gr_context, + scale_factor, + ) = init_window(800, 600); + + // Create renderer + let mut renderer = Renderer::new(); + renderer.set_backend(Backend::GL(surface_ptr)); + + // Create static scene + let scene = create_static_scene(); + + // Create camera + let mut camera = Camera2D::new(Size { + width: 800.0, + height: 600.0, + }); + camera.set_position(400.0, 300.0); + camera.set_zoom(1.0); + renderer.set_camera(camera); + + // Load and warm up the scene cache + renderer.load_scene(scene.clone()); + renderer.queue(); + + // Benchmark rendering with camera transformations + let mut frame_count = 0; + let mut total_time = 0.0; + let start_time = std::time::Instant::now(); + + el.run(move |event, elwt| { + match event { + Event::WindowEvent { + event: WindowEvent::CloseRequested, + .. + } => { + elwt.exit(); + } + Event::WindowEvent { + event: WindowEvent::Resized(size), + .. + } => { + // Recreate GL surface + let attrs = SurfaceAttributesBuilder::::new().build( + window + .raw_window_handle() + .expect("Failed to get window handle"), + NonZeroU32::new(size.width).unwrap(), + NonZeroU32::new(size.height).unwrap(), + ); + let new_gl_surface = unsafe { + gl_config + .display() + .create_window_surface(&gl_config, &attrs) + .expect("Could not create gl window surface") + }; + + // Recreate Skia surface + let backend_render_target = gpu::backend_render_targets::make_gl( + (size.width as i32, size.height as i32), + gl_config.num_samples() as usize, + gl_config.stencil_size() as usize, + fb_info, + ); + let surface = gpu::surfaces::wrap_backend_render_target( + &mut gr_context, + &backend_render_target, + skia_safe::gpu::SurfaceOrigin::BottomLeft, + skia_safe::ColorType::RGBA8888, + None, + None, + ) + .expect("Could not create skia surface"); + + // Update surface pointer + unsafe { _ = Box::from_raw(surface_ptr) }; + let new_surface_ptr = Box::into_raw(Box::new(surface)); + renderer.set_backend(Backend::GL(new_surface_ptr)); + } + Event::AboutToWait => { + let frame_start = std::time::Instant::now(); + + // Update camera position in a circular motion + let elapsed = start_time.elapsed().as_secs_f32(); + let angle = elapsed * 2.0; + let x = 400.0 + angle.cos() * 100.0; + let y = 300.0 + angle.sin() * 100.0; + renderer.camera.as_mut().unwrap().set_position(x, y); + + // Render the scene + renderer.queue(); + + if let Err(e) = gl_surface.swap_buffers(&gl_context) { + eprintln!("Error swapping buffers: {:?}", e); + } + + let frame_time = frame_start.elapsed().as_secs_f32(); + total_time += frame_time; + frame_count += 1; + + if frame_count % 60 == 0 { + let avg_time = total_time / frame_count as f32; + println!( + "Frame {}: {:.3}ms (avg: {:.3}ms, FPS: {:.1})", + frame_count, + frame_time * 1000.0, + avg_time * 1000.0, + 1.0 / avg_time + ); + } + + window.request_redraw(); + } + _ => {} + } + }) + .unwrap(); + + // Clean up + unsafe { + _ = Box::from_raw(surface_ptr); + } +} diff --git a/crates/grida-canvas/examples/container.rs b/crates/grida-canvas/examples/container.rs new file mode 100644 index 0000000000..27e115954c --- /dev/null +++ b/crates/grida-canvas/examples/container.rs @@ -0,0 +1,79 @@ +use cg::node::factory::NodeFactory; +use cg::node::repository::NodeRepository; +use cg::node::schema::*; +use cg::window; +use math2::transform::AffineTransform; + +async fn demo_clip() -> Scene { + let nf = NodeFactory::new(); + let mut repository = NodeRepository::new(); + + // Create a single container with solid fill + let mut container = nf.create_container_node(); + container.base.name = "Simple Container".to_string(); + container.transform = AffineTransform::new(100.0, 100.0, 0.0); + container.size = Size { + width: 300.0, + height: 300.0, + }; + container.corner_radius = RectangularCornerRadius::all(20.0); + container.fill = Paint::Solid(SolidPaint { + color: Color(240, 100, 100, 255), // Light red + opacity: 1.0, + }); + container.stroke = Some(Paint::Solid(SolidPaint { + color: Color(200, 50, 50, 255), // Darker red + opacity: 1.0, + })); + container.effect = Some(FilterEffect::DropShadow(FeDropShadow { + dx: 0.0, + dy: 0.0, + blur: 10.0, + color: Color(0, 0, 0, 255), + })); + container.clip = true; + container.stroke_width = 2.0; + + // Create an ellipse + let mut ellipse = nf.create_ellipse_node(); + ellipse.base.name = "Simple Ellipse".to_string(); + ellipse.transform = AffineTransform::new(100.0, 150.0, 0.0); // Position below container + ellipse.size = Size { + width: 300.0, + height: 200.0, + }; + ellipse.fill = Paint::Solid(SolidPaint { + color: Color(100, 200, 100, 255), // Light green + opacity: 1.0, + }); + ellipse.stroke = Paint::Solid(SolidPaint { + color: Color(50, 150, 50, 255), // Darker green + opacity: 1.0, + }); + ellipse.stroke_width = 2.0; + + // Add nodes to repository and collect their IDs + let ellipse_id = ellipse.base.id.clone(); + repository.insert(Node::Ellipse(ellipse)); + + // Add ellipse as child of container + container.children = vec![ellipse_id]; + + let container_id = container.base.id.clone(); + repository.insert(Node::Container(container)); + + Scene { + id: "scene".to_string(), + name: "Simple Container Demo".to_string(), + transform: AffineTransform::identity(), + children: vec![container_id], + nodes: repository, + background_color: None, + } +} + +#[tokio::main] +async fn main() { + let scene = demo_clip().await; + window::run_demo_window(scene).await; +} diff --git a/crates/grida-canvas/examples/effects.rs b/crates/grida-canvas/examples/effects.rs new file mode 100644 index 0000000000..e0773b5f65 --- /dev/null +++ b/crates/grida-canvas/examples/effects.rs @@ -0,0 +1,208 @@ +use cg::node::factory::NodeFactory; +use cg::node::repository::NodeRepository; +use cg::node::schema::*; +use cg::window; +use math2::transform::AffineTransform; + +async fn demo_effects() -> Scene { + let nf = NodeFactory::new(); + let mut repository = NodeRepository::new(); + + // Create a root container node + let mut root_container_node = nf.create_container_node(); + root_container_node.size = Size { + width: 2000.0, + height: 2000.0, + }; + root_container_node.base.name = "Root Container".to_string(); + + let mut all_effect_ids = Vec::new(); + let spacing = 200.0; + let start_x = 50.0; + let base_size = 150.0; + + // Row 1: Drop Shadow Variations + for i in 0..6 { + if i < 3 { + // First three shapes as rectangles + let mut rect = nf.create_rectangle_node(); + rect.base.name = format!("Drop Shadow Rect {}", i + 1); + rect.transform = AffineTransform::new(start_x + spacing * i as f32, 100.0, 0.0); + rect.size = Size { + width: base_size, + height: base_size, + }; + rect.corner_radius = RectangularCornerRadius::all(20.0); + rect.fill = Paint::Solid(SolidPaint { + color: Color(255, 255, 255, 255), // White + opacity: 1.0, + }); + rect.effect = Some(FilterEffect::DropShadow(FeDropShadow { + dx: 4.0, + dy: 4.0, + blur: 4.0 * (i + 1) as f32, + color: Color(0, 0, 0, 128), + })); + all_effect_ids.push(rect.base.id.clone()); + repository.insert(Node::Rectangle(rect)); + } else { + // Last two shapes as regular polygons + let mut polygon = nf.create_regular_polygon_node(); + polygon.base.name = format!("Drop Shadow Polygon {}", i + 1); + polygon.transform = AffineTransform::new(start_x + spacing * i as f32, 100.0, 0.0); + polygon.size = Size { + width: base_size, + height: base_size, + }; + polygon.point_count = i + 3; + polygon.fill = Paint::Solid(SolidPaint { + color: Color(255, 255, 255, 255), // White + opacity: 1.0, + }); + polygon.effect = Some(FilterEffect::DropShadow(FeDropShadow { + dx: 4.0, + dy: 4.0, + blur: 4.0 * (i + 1) as f32, + color: Color(0, 0, 0, 128), + })); + all_effect_ids.push(polygon.base.id.clone()); + repository.insert(Node::RegularPolygon(polygon)); + } + } + + // Row 2: Gaussian Blur Variations + for i in 0..6 { + if i < 3 { + // First three shapes as rectangles + let mut rect = nf.create_rectangle_node(); + rect.base.name = format!("Gaussian Blur Rect {}", i + 1); + rect.transform = AffineTransform::new(start_x + spacing * i as f32, 300.0, 0.0); + rect.size = Size { + width: base_size, + height: base_size, + }; + rect.corner_radius = RectangularCornerRadius::all(20.0); + rect.fill = Paint::Solid(SolidPaint { + color: Color(200, 200, 200, 255), // White + opacity: 1.0, + }); + rect.effect = Some(FilterEffect::GaussianBlur(FeGaussianBlur { + radius: 4.0 * (i + 1) as f32, + })); + all_effect_ids.push(rect.base.id.clone()); + repository.insert(Node::Rectangle(rect)); + } else { + // Last two shapes as regular polygons + let mut polygon = nf.create_regular_polygon_node(); + polygon.base.name = format!("Gaussian Blur Polygon {}", i + 1); + polygon.transform = AffineTransform::new(start_x + spacing * i as f32, 300.0, 0.0); + polygon.size = Size { + width: base_size, + height: base_size, + }; + polygon.point_count = i + 3; + polygon.fill = Paint::Solid(SolidPaint { + color: Color(200, 200, 200, 255), // White + opacity: 1.0, + }); + polygon.effect = Some(FilterEffect::GaussianBlur(FeGaussianBlur { + radius: 4.0 * (i + 1) as f32, + })); + all_effect_ids.push(polygon.base.id.clone()); + repository.insert(Node::RegularPolygon(polygon)); + } + } + + // Row 3: Backdrop Blur Variations + // Add a vivid gradient background behind Row 2 (Backdrop Blur Variations) + let mut vivid_gradient_rect = nf.create_rectangle_node(); + vivid_gradient_rect.base.name = "Vivid Gradient Row2".to_string(); + vivid_gradient_rect.transform = AffineTransform::new(0.0, 530.0, 0.0); // y middle of row 2 + vivid_gradient_rect.size = Size { + width: 2000.0, + height: 90.0, + }; + vivid_gradient_rect.fill = Paint::LinearGradient(LinearGradientPaint { + transform: AffineTransform::identity(), + stops: vec![ + GradientStop { + offset: 0.0, + color: Color(255, 0, 128, 255), + }, // Pink + GradientStop { + offset: 0.5, + color: Color(0, 255, 255, 255), + }, // Cyan + GradientStop { + offset: 1.0, + color: Color(255, 255, 0, 255), + }, // Yellow + ], + opacity: 1.0, + }); + let vivid_gradient_rect_id = vivid_gradient_rect.base.id.clone(); + repository.insert(Node::Rectangle(vivid_gradient_rect)); + + for i in 0..6 { + if i < 3 { + // First three shapes as rectangles + let mut blur_rect = nf.create_rectangle_node(); + blur_rect.base.name = format!("Backdrop Blur Rect {}", i + 1); + blur_rect.transform = AffineTransform::new(start_x + spacing * i as f32, 500.0, 0.0); + blur_rect.size = Size { + width: base_size, + height: base_size, + }; + blur_rect.corner_radius = RectangularCornerRadius::all(20.0); + blur_rect.fill = Paint::Solid(SolidPaint { + color: Color(255, 255, 255, 128), // Semi-transparent white + opacity: 1.0, + }); + blur_rect.effect = Some(FilterEffect::BackdropBlur(FeBackdropBlur { + radius: 8.0 * (i + 1) as f32, + })); + all_effect_ids.push(blur_rect.base.id.clone()); + repository.insert(Node::Rectangle(blur_rect)); + } else { + // Last two shapes as regular polygons + let mut blur_polygon = nf.create_regular_polygon_node(); + blur_polygon.base.name = format!("Backdrop Blur Polygon {}", i + 1); + blur_polygon.transform = AffineTransform::new(start_x + spacing * i as f32, 500.0, 0.0); + blur_polygon.size = Size { + width: base_size, + height: base_size, + }; + blur_polygon.point_count = i + 3; + blur_polygon.fill = Paint::Solid(SolidPaint { + color: Color(255, 255, 255, 128), // Semi-transparent white + opacity: 1.0, + }); + blur_polygon.effect = Some(FilterEffect::BackdropBlur(FeBackdropBlur { + radius: 8.0 * (i + 1) as f32, + })); + all_effect_ids.push(blur_polygon.base.id.clone()); + repository.insert(Node::RegularPolygon(blur_polygon)); + } + } + + // Set up the root container + root_container_node.children = vec![vivid_gradient_rect_id]; + root_container_node.children.extend(all_effect_ids); + let root_container_id = root_container_node.base.id.clone(); + repository.insert(Node::Container(root_container_node)); + + Scene { + id: "scene".to_string(), + name: "Effects Demo".to_string(), + transform: AffineTransform::identity(), + children: vec![root_container_id], + nodes: repository, + background_color: Some(Color(250, 250, 250, 255)), + } +} + +#[tokio::main] +async fn main() { + let scene = demo_effects().await; + window::run_demo_window(scene).await; +} diff --git a/crates/grida-canvas/examples/figma.rs b/crates/grida-canvas/examples/figma.rs new file mode 100644 index 0000000000..4bcfee7500 --- /dev/null +++ b/crates/grida-canvas/examples/figma.rs @@ -0,0 +1,232 @@ +use cg::font_loader::FontLoader; +use cg::image_loader::{ImageLoader, load_scene_images}; +use cg::webfont_helper::{find_font_files, load_webfonts_metadata}; +use cg::window; +use cg::{io::io_figma::FigmaConverter, node::schema::Scene}; +use clap::Parser; +use figma_api::apis::{ + configuration::{ApiKey, Configuration}, + files_api::{get_file, get_image_fills}, +}; +use futures::future::join_all; + +#[derive(Parser)] +#[command(author, version, about, long_about = None)] +struct Cli { + #[arg(long = "file-key")] + file_key: Option, + #[arg(long = "api-key")] + api_key: Option, + #[arg(long = "scene-index")] + scene_index: usize, + #[arg(long = "no-image")] + no_image: bool, + #[arg(long = "file")] + file: Option, + #[arg(long = "images")] + images_dir: Option, +} + +async fn load_scene_from_url( + file_key: Option<&str>, + api_key: Option<&str>, + scene_index: usize, + no_image: bool, + file_path: Option<&str>, + images_dir: Option<&str>, +) -> Result<(Scene, FigmaConverter), String> { + let file = if let Some(file_path) = file_path { + // Load from local file + let file_content = std::fs::read_to_string(file_path) + .map_err(|e| format!("Failed to read file: {}", e))?; + serde_json::from_str(&file_content).map_err(|e| format!("Failed to parse JSON: {}", e))? + } else { + // Load from Figma API + let file_key = file_key.ok_or("file-key is required when not using --file")?; + let api_key = api_key.ok_or("api-key is required when not using --file")?; + + let configuration = Configuration { + base_path: "https://api.figma.com".to_string(), + user_agent: None, + client: reqwest::Client::new(), + basic_auth: None, + oauth_access_token: None, + bearer_access_token: None, + api_key: Some(ApiKey { + key: api_key.to_string(), + prefix: None, + }), + }; + + get_file( + &configuration, + file_key, + None, + None, + None, + Some("paths"), + None, + None, + ) + .await + .expect("Failed to load file") + }; + + let images = if no_image { + println!("Skipping image loading (--no-image flag)"); + std::collections::HashMap::new() + } else if let Some(images_dir) = images_dir { + // Load images from local directory + let mut images = std::collections::HashMap::new(); + let dir = std::fs::read_dir(images_dir) + .map_err(|e| format!("Failed to read images directory: {}", e))?; + + for entry in dir { + let entry = entry.map_err(|e| format!("Failed to read directory entry: {}", e))?; + let path = entry.path(); + if path.is_file() { + let key = path + .file_stem() + .ok_or_else(|| format!("Invalid filename: {:?}", path))? + .to_string_lossy() + .to_string(); + let url = path.to_string_lossy().to_string(); + images.insert(key, url); + } + } + + println!("Loaded {} images from directory", images.len()); + images + } else if file_path.is_some() { + // When loading from local file without --images, skip image loading + println!("Skipping image loading (loading from local file without --images directory)"); + std::collections::HashMap::new() + } else { + // Load from Figma API (only when not loading from local file) + println!("Loading images from Figma API"); + let file_key = file_key.ok_or("file-key is required when not using --file")?; + let api_key = api_key.ok_or("api-key is required when not using --file")?; + + let configuration = Configuration { + base_path: "https://api.figma.com".to_string(), + user_agent: None, + client: reqwest::Client::new(), + basic_auth: None, + oauth_access_token: None, + bearer_access_token: None, + api_key: Some(ApiKey { + key: api_key.to_string(), + prefix: None, + }), + }; + + let images_response = get_image_fills(&configuration, file_key) + .await + .expect("Failed to load images"); + images_response.meta.images + }; + + let mut converter = FigmaConverter::new().with_image_urls(images); + + let document = converter + .convert_document(&file.document) + .expect("Failed to convert document"); + + Ok((document[scene_index].clone(), converter)) +} + +#[tokio::main] +async fn main() { + let cli = Cli::parse(); + let (scene, converter) = load_scene_from_url( + cli.file_key.as_deref(), + cli.api_key.as_deref(), + cli.scene_index, + cli.no_image, + cli.file.as_deref(), + cli.images_dir.as_deref(), + ) + .await + .expect("Failed to load scene"); + + println!("Rendering scene: {}", scene.name); + println!("Scene ID: {}", scene.id); + println!("Number of children: {}", scene.children.len()); + println!("Total nodes in repository: {}", scene.nodes.len()); + + // Load webfonts metadata and find matching font files + let webfonts_metadata = load_webfonts_metadata() + .await + .expect("Failed to load webfonts metadata"); + let font_files = find_font_files(&webfonts_metadata, &converter.get_discovered_fonts()); + println!("\nFound {} matching font files:", font_files.len()); + for font_file in &font_files { + println!("Font: {} ({})", font_file.family, font_file.postscript_name); + println!(" Style: {}", font_file.style); + println!(" URL: {}", font_file.url); + println!(); + } + + // Clone the scene before passing it to run_demo_window_with + let scene_for_window = scene.clone(); + let scene_for_loader = scene; + + // Use the window module's run_demo_window_with to handle image loading and font loading + window::run_demo_window_with(scene_for_window, |_renderer, tx, font_tx, proxy| { + // Initialize the image loader in lifecycle mode + println!("📸 Initializing image loader..."); + let mut image_loader = ImageLoader::new_lifecycle(tx, proxy.clone()); + + // Initialize the font loader in lifecycle mode + println!("📝 Initializing font loader..."); + let font_tx_clone = font_tx.clone(); + let proxy_clone = proxy.clone(); + + // Load all images in the scene - non-blocking + let should_load_images = !cli.no_image && (cli.file.is_none() || cli.images_dir.is_some()); + if should_load_images { + println!("🔄 Starting to load scene images in background..."); + let scene_for_images = scene_for_loader.clone(); + tokio::spawn(async move { + load_scene_images(&mut image_loader, &scene_for_images).await; + println!("✅ Scene images loading completed in background"); + }); + } else { + if cli.no_image { + println!("⏭️ Skipping image loading as --no-image flag is set"); + } else if cli.file.is_some() && cli.images_dir.is_none() { + println!( + "⏭️ Skipping image loading (loading from local file without --images directory)" + ); + } + } + + // Load all fonts in the scene - non-blocking + println!("🔄 Starting to load scene fonts in background..."); + let font_files_clone = font_files.clone(); + let font_tx = font_tx_clone; + let proxy = proxy_clone; + tokio::spawn(async move { + let font_loading_futures: Vec<_> = font_files_clone + .into_iter() + .map(|font_file| { + let font_tx = font_tx.clone(); + let proxy = proxy.clone(); + async move { + let family = font_file.family; + let url = font_file.url; + let postscript_name = font_file.postscript_name; + println!("Loading font: {} ({})", family, postscript_name); + let mut font_loader = FontLoader::new_lifecycle(font_tx, proxy); + font_loader.load_font(&family, &url).await; + println!("✅ Font loaded: {} ({})", family, postscript_name); + } + }) + .collect(); + + join_all(font_loading_futures).await; + println!("✅ Scene fonts loading completed in background"); + }); + }) + .await; +} diff --git a/crates/grida-canvas/examples/fonts.rs b/crates/grida-canvas/examples/fonts.rs new file mode 100644 index 0000000000..d6d587b905 --- /dev/null +++ b/crates/grida-canvas/examples/fonts.rs @@ -0,0 +1,120 @@ +use skia_safe::textlayout::FontCollection; +use skia_safe::textlayout::{ + ParagraphBuilder, ParagraphStyle, TextAlign, TextDirection, TextStyle, TypefaceFontProvider, +}; +use skia_safe::{Color, Font, FontMgr, Paint, Point, Surface}; +use std::fs; + +fn main() { + // Create a surface to draw on + #[allow(deprecated)] + let mut surface = Surface::new_raster_n32_premul((400, 800)).unwrap(); + let canvas = surface.canvas(); + + // Clear the canvas with white background + canvas.clear(Color::WHITE); + + // Create a paint for text + let mut paint = Paint::default(); + paint.set_anti_alias(true); + paint.set_color(Color::BLACK); + + // Load the Caveat font from local resources + let font_data = fs::read("resources/Caveat-VariableFont_wght.ttf").unwrap(); + let font_mgr = FontMgr::new(); + let typeface = font_mgr.new_from_data(&font_data, None).unwrap(); + let font = Font::new(typeface, 24.0); + + // Draw text with Caveat + let text = "Hello, Skia!"; + let point = Point::new(50.0, 100.0); + canvas.draw_str(text, point, &font, &paint); + + // Try to load Bungee font + if let Ok(bungee_data) = fs::read("resources/Bungee-Regular.ttf") { + if let Some(bungee_typeface) = font_mgr.new_from_data(&bungee_data, None) { + let bungee_font = Font::new(bungee_typeface, 24.0); + // Draw text with Bungee + let bungee_point = Point::new(50.0, 200.0); + canvas.draw_str("Bungee Font!", bungee_point, &bungee_font, &paint); + } + } else { + // If Bungee font is not found, draw a message + let fallback_point = Point::new(50.0, 200.0); + canvas.draw_str("Bungee font not found", fallback_point, &font, &paint); + } + + // Try to load Fruktur font + if let Ok(fruktur_data) = fs::read("resources/Fruktur-Regular.ttf") { + if let Some(fruktur_typeface) = font_mgr.new_from_data(&fruktur_data, None) { + let fruktur_font = Font::new(fruktur_typeface, 24.0); + // Draw text with Fruktur + let fruktur_point = Point::new(50.0, 300.0); + canvas.draw_str("Fruktur Font!", fruktur_point, &fruktur_font, &paint); + } + } else { + // If Fruktur font is not found, draw a message + let fallback_point = Point::new(50.0, 300.0); + canvas.draw_str("Fruktur font not found", fallback_point, &font, &paint); + } + + // Try to load VT323 font + if let Ok(vt323_data) = fs::read("resources/VT323-Regular.ttf") { + if let Some(vt323_typeface) = font_mgr.new_from_data(&vt323_data, None) { + let vt323_font = Font::new(vt323_typeface.clone(), 24.0); + // Draw text with VT323 + let vt323_point = Point::new(50.0, 400.0); + canvas.draw_str("VT323 Font!", vt323_point, &vt323_font, &paint); + + // Add paragraph demo with VT323 + let paragraph_text = "Welcome to the VT323 font demo! This is a monospace font that's perfect for coding and retro-style interfaces. It has a distinctive pixelated look that makes it stand out. The font was designed by Peter Hull and is inspired by the classic VT320 terminal."; + let paragraph_point = Point::new(50.0, 450.0); + + // Create a paragraph style + let mut paragraph_style = ParagraphStyle::new(); + paragraph_style.set_text_direction(TextDirection::LTR); + paragraph_style.set_text_align(TextAlign::Left); + + // Create a font collection and add the VT323 font + let mut font_collection = FontCollection::new(); + let mut provider = TypefaceFontProvider::new(); + provider.register_typeface( + font_mgr.new_from_data(&vt323_data, None).unwrap(), + Some("VT323"), + ); + font_collection.set_asset_font_manager(Some(provider.into())); + font_collection.set_default_font_manager(font_mgr.clone(), None); + + // Create a paragraph builder + let mut para_builder = ParagraphBuilder::new(¶graph_style, &font_collection); + + // Add text with the VT323 font + let mut text_style = TextStyle::new(); + text_style.set_foreground_paint(&paint); + text_style.set_font_size(20.0); + text_style.set_font_families(&["VT323"]); + para_builder.push_style(&text_style); + para_builder.add_text(paragraph_text); + + // Build and layout the paragraph + let mut paragraph = para_builder.build(); + paragraph.layout(300.0); // Set width to 300 pixels + + // Draw the paragraph + paragraph.paint(canvas, paragraph_point); + } + } else { + // If VT323 font is not found, draw a message + let fallback_point = Point::new(50.0, 400.0); + canvas.draw_str("VT323 font not found", fallback_point, &font, &paint); + } + + // Save the result to a PNG file + let image = surface.image_snapshot(); + #[allow(deprecated)] + let data = image + .encode_to_data(skia_safe::EncodedImageFormat::PNG) + .unwrap(); + let bytes = data.as_bytes(); + std::fs::write("text_output.png", bytes).unwrap(); +} diff --git a/crates/grida-canvas/examples/gradients.rs b/crates/grida-canvas/examples/gradients.rs new file mode 100644 index 0000000000..bb6b08c4be --- /dev/null +++ b/crates/grida-canvas/examples/gradients.rs @@ -0,0 +1,172 @@ +use cg::node::factory::NodeFactory; +use cg::node::repository::NodeRepository; +use cg::node::schema::*; +use cg::window; +use math2::transform::AffineTransform; + +async fn demo_gradients() -> Scene { + let nf = NodeFactory::new(); + let mut repository = NodeRepository::new(); + + // root container + let mut root = nf.create_container_node(); + root.base.name = "Root".to_string(); + root.size = Size { + width: 1200.0, + height: 800.0, + }; + + let mut ids = Vec::new(); + let spacing = 160.0; + let start_x = 60.0; + let base = 120.0; + + // Linear gradient fills + for i in 0..5 { + let mut rect = nf.create_rectangle_node(); + rect.base.name = format!("Linear Fill {}", i); + rect.transform = AffineTransform::new(start_x + spacing * i as f32, 80.0, 0.0); + rect.size = Size { + width: base, + height: base, + }; + rect.corner_radius = RectangularCornerRadius::all(8.0); + let angle = (i as f32) * 45.0; + rect.fill = Paint::LinearGradient(LinearGradientPaint { + transform: AffineTransform::from_rotatation(angle), + stops: vec![ + GradientStop { + offset: 0.0, + color: Color(255, 0, 0, 255), + }, + GradientStop { + offset: 1.0, + color: Color(0, 0, 255, 255), + }, + ], + opacity: 1.0, + }); + ids.push(rect.base.id.clone()); + repository.insert(Node::Rectangle(rect)); + } + + // Radial gradient fills + for i in 0..5 { + let mut rect = nf.create_rectangle_node(); + rect.base.name = format!("Radial Fill {}", i); + rect.transform = AffineTransform::new(start_x + spacing * i as f32, 280.0, 0.0); + rect.size = Size { + width: base, + height: base, + }; + rect.corner_radius = RectangularCornerRadius::all(8.0); + let offset = -0.25 + 0.125 * i as f32; + rect.fill = Paint::RadialGradient(RadialGradientPaint { + transform: AffineTransform { + matrix: [[1.0, 0.0, offset], [0.0, 1.0, offset]], + }, + stops: vec![ + GradientStop { + offset: 0.0, + color: Color(255, 255, 0, 255), + }, + GradientStop { + offset: 1.0, + color: Color(0, 255, 0, 255), + }, + ], + opacity: 1.0, + }); + ids.push(rect.base.id.clone()); + repository.insert(Node::Rectangle(rect)); + } + + // Linear gradient strokes + for i in 0..5 { + let mut rect = nf.create_rectangle_node(); + rect.base.name = format!("Linear Stroke {}", i); + rect.transform = AffineTransform::new(start_x + spacing * i as f32, 480.0, 0.0); + rect.size = Size { + width: base, + height: base, + }; + rect.corner_radius = RectangularCornerRadius::all(8.0); + rect.fill = Paint::Solid(SolidPaint { + color: Color(0, 0, 0, 0), + opacity: 1.0, + }); + let angle = (i as f32) * 45.0; + rect.stroke = Paint::LinearGradient(LinearGradientPaint { + transform: AffineTransform::from_rotatation(angle), + stops: vec![ + GradientStop { + offset: 0.0, + color: Color(255, 0, 255, 255), + }, + GradientStop { + offset: 1.0, + color: Color(0, 255, 255, 255), + }, + ], + opacity: 1.0, + }); + rect.stroke_width = 8.0; + ids.push(rect.base.id.clone()); + repository.insert(Node::Rectangle(rect)); + } + + // Radial gradient strokes + for i in 0..5 { + let mut rect = nf.create_rectangle_node(); + rect.base.name = format!("Radial Stroke {}", i); + rect.transform = AffineTransform::new(start_x + spacing * i as f32, 680.0, 0.0); + rect.size = Size { + width: base, + height: base, + }; + rect.corner_radius = RectangularCornerRadius::all(8.0); + rect.fill = Paint::Solid(SolidPaint { + color: Color(0, 0, 0, 0), + opacity: 1.0, + }); + let offset = -0.25 + 0.125 * i as f32; + rect.stroke = Paint::RadialGradient(RadialGradientPaint { + transform: AffineTransform { + matrix: [[1.0, 0.0, offset], [0.0, 1.0, offset]], + }, + stops: vec![ + GradientStop { + offset: 0.0, + color: Color(255, 128, 0, 255), + }, + GradientStop { + offset: 1.0, + color: Color(0, 128, 255, 255), + }, + ], + opacity: 1.0, + }); + rect.stroke_width = 8.0; + ids.push(rect.base.id.clone()); + repository.insert(Node::Rectangle(rect)); + } + + root.children = ids.clone(); + let root_id = root.base.id.clone(); + repository.insert(Node::Container(root)); + + Scene { + id: "scene".to_string(), + name: "Gradients Demo".to_string(), + transform: AffineTransform::identity(), + children: vec![root_id], + nodes: repository, + background_color: Some(Color(250, 250, 250, 255)), + } +} + +#[tokio::main] +async fn main() { + let scene = demo_gradients().await; + window::run_demo_window(scene).await; +} diff --git a/crates/grida-canvas/examples/image.rs b/crates/grida-canvas/examples/image.rs new file mode 100644 index 0000000000..1a949f469f --- /dev/null +++ b/crates/grida-canvas/examples/image.rs @@ -0,0 +1,66 @@ +use cg::node::factory::NodeFactory; +use cg::node::repository::NodeRepository; +use cg::node::schema::*; +use cg::window; +use math2::{box_fit::BoxFit, transform::AffineTransform}; + +async fn demo_image() -> Scene { + let nf = NodeFactory::new(); + // let image4k = "resources/4k.jpg".to_string(); + // let image4ksize = Size { + // width: 4000.0, + // height: 6000.0, + // }; + let image8k = "resources/8k.jpg".to_string(); + let image8ksize = Size { + width: 8070.0, + height: 5196.0, + }; + + // Root container + let mut root = nf.create_container_node(); + root.base.name = "Root".to_string(); + root.size = image8ksize.clone(); + + // First example: Rectangle with ImagePaint fill + let mut rect1 = nf.create_rectangle_node(); + rect1.base.name = "ImageFillRect".to_string(); + rect1.transform = AffineTransform::identity(); + rect1.size = image8ksize.clone(); + rect1.fill = Paint::Image(ImagePaint { + _ref: image8k.clone(), + opacity: 1.0, + transform: AffineTransform::identity(), + fit: BoxFit::Cover, + }); + rect1.stroke = Paint::Solid(SolidPaint { + color: Color(255, 0, 0, 255), + opacity: 1.0, + }); + rect1.stroke_width = 2.0; + + let mut repository = NodeRepository::new(); + + let rect1_id = rect1.base.id.clone(); + + repository.insert(Node::Rectangle(rect1)); + + root.children = vec![rect1_id]; + let root_id = root.base.id.clone(); + repository.insert(Node::Container(root)); + + Scene { + id: "scene".to_string(), + name: "Images Demo".to_string(), + transform: AffineTransform::identity(), + children: vec![root_id], + nodes: repository, + background_color: Some(Color(250, 250, 250, 255)), + } +} + +#[tokio::main] +async fn main() { + let scene = demo_image().await; + window::run_demo_window(scene).await; +} diff --git a/crates/grida-canvas/examples/images.rs b/crates/grida-canvas/examples/images.rs new file mode 100644 index 0000000000..f533cf7586 --- /dev/null +++ b/crates/grida-canvas/examples/images.rs @@ -0,0 +1,130 @@ +use cg::node::factory::NodeFactory; +use cg::node::repository::NodeRepository; +use cg::node::schema::*; +use cg::window; +use math2::{box_fit::BoxFit, transform::AffineTransform}; + +async fn demo_images() -> Scene { + let nf = NodeFactory::new(); + let image_url = "https://grida.co/images/abstract-placeholder.jpg".to_string(); + + // Root container + let mut root = nf.create_container_node(); + root.base.name = "Root".to_string(); + root.size = Size { + width: 800.0, + height: 600.0, + }; + + // First example: Rectangle with ImagePaint fill + let mut rect1 = nf.create_rectangle_node(); + rect1.base.name = "ImageFillRect".to_string(); + rect1.transform = AffineTransform::new(50.0, 50.0, 0.0); + rect1.size = Size { + width: 200.0, + height: 200.0, + }; + rect1.fill = Paint::Image(ImagePaint { + _ref: image_url.clone(), + opacity: 1.0, + transform: AffineTransform::identity(), + fit: BoxFit::Cover, + }); + rect1.stroke = Paint::Solid(SolidPaint { + color: Color(255, 0, 0, 255), + opacity: 1.0, + }); + rect1.stroke_width = 2.0; + + // Second example: Rectangle with ImagePaint fill and stroke + let mut rect2 = nf.create_rectangle_node(); + rect2.base.name = "ImageFillAndStrokeRect".to_string(); + rect2.transform = AffineTransform::new(300.0, 50.0, 0.0); + rect2.size = Size { + width: 200.0, + height: 200.0, + }; + rect2.fill = Paint::Image(ImagePaint { + _ref: image_url.clone(), + opacity: 1.0, + transform: AffineTransform::identity(), + fit: BoxFit::Cover, + }); + rect2.stroke = Paint::Image(ImagePaint { + _ref: image_url.clone(), + opacity: 1.0, + transform: AffineTransform::identity(), + fit: BoxFit::Cover, + }); + rect2.stroke_width = 10.0; + + // Third example: Rectangle with ImagePaint stroke only + let mut rect3 = nf.create_rectangle_node(); + rect3.base.name = "ImageStrokeRect".to_string(); + rect3.transform = AffineTransform::new(550.0, 50.0, 0.0); + rect3.size = Size { + width: 200.0, + height: 200.0, + }; + rect3.corner_radius = RectangularCornerRadius::all(40.0); + rect3.fill = Paint::Solid(SolidPaint { + color: Color(240, 240, 240, 255), + opacity: 1.0, + }); + rect3.stroke = Paint::Image(ImagePaint { + _ref: image_url.clone(), + opacity: 1.0, + transform: AffineTransform::identity(), + fit: BoxFit::Cover, + }); + rect3.stroke_width = 10.0; + + // Fourth example: Rectangle with ImagePaint fill using a custom transform + let mut rect4 = nf.create_rectangle_node(); + rect4.base.name = "ImageTransformFillRect".to_string(); + rect4.transform = AffineTransform::new(50.0, 300.0, 0.0); + rect4.size = Size { + width: 200.0, + height: 200.0, + }; + rect4.fill = Paint::Image(ImagePaint { + _ref: image_url.clone(), + opacity: 1.0, + // Rotate the image 45 degrees with BoxFit::None to showcase the paint transform + transform: AffineTransform { + matrix: [[0.7071, -0.7071, 100.0], [0.7071, 0.7071, 0.0]], + }, + fit: BoxFit::None, + }); + + let mut repository = NodeRepository::new(); + + let rect1_id = rect1.base.id.clone(); + let rect2_id = rect2.base.id.clone(); + let rect3_id = rect3.base.id.clone(); + let rect4_id = rect4.base.id.clone(); + + repository.insert(Node::Rectangle(rect1)); + repository.insert(Node::Rectangle(rect2)); + repository.insert(Node::Rectangle(rect3)); + repository.insert(Node::Rectangle(rect4)); + + root.children = vec![rect1_id, rect2_id, rect3_id, rect4_id]; + let root_id = root.base.id.clone(); + repository.insert(Node::Container(root)); + + Scene { + id: "scene".to_string(), + name: "Images Demo".to_string(), + transform: AffineTransform::identity(), + children: vec![root_id], + nodes: repository, + background_color: Some(Color(250, 250, 250, 255)), + } +} + +#[tokio::main] +async fn main() { + let scene = demo_images().await; + window::run_demo_window(scene).await; +} diff --git a/crates/grida-canvas/examples/json.rs b/crates/grida-canvas/examples/json.rs new file mode 100644 index 0000000000..ff736a955d --- /dev/null +++ b/crates/grida-canvas/examples/json.rs @@ -0,0 +1,46 @@ +use cg::io::io_json::parse; +use cg::node::schema::*; +use cg::window; +use clap::Parser; +use math2::transform::AffineTransform; +use std::fs; + +#[derive(Parser)] +#[command(author, version, about, long_about = None)] +struct Cli { + // take path to json file + path: String, +} + +async fn load_scene_from_file(file_path: &str) -> Scene { + let file: String = fs::read_to_string(file_path).expect("failed to read file"); + let canvas_file = parse(&file).expect("failed to parse file"); + let nodes = canvas_file.document.nodes; + // entry_scene_id or scenes[0] + let scene_id = canvas_file.document.entry_scene_id.unwrap_or( + canvas_file + .document + .scenes + .keys() + .next() + .unwrap() + .to_string(), + ); + let scene = canvas_file.document.scenes.get(&scene_id).unwrap(); + Scene { + nodes: nodes.into_iter().map(|(k, v)| (k, v.into())).collect(), + id: scene_id, + name: scene.name.clone(), + transform: AffineTransform::identity(), + children: scene.children.clone(), + background_color: Some(Color(230, 230, 230, 255)), + } +} + +#[tokio::main] +async fn main() { + let cli = Cli::parse(); + let scene = load_scene_from_file(&cli.path).await; + + window::run_demo_window(scene).await; +} diff --git a/crates/grida-canvas/examples/nested.rs b/crates/grida-canvas/examples/nested.rs new file mode 100644 index 0000000000..8b53589b95 --- /dev/null +++ b/crates/grida-canvas/examples/nested.rs @@ -0,0 +1,100 @@ +use cg::node::factory::NodeFactory; +use cg::node::repository::NodeRepository; +use cg::node::schema::*; +use cg::window; +use math2::transform::AffineTransform; + +async fn demo_nested() -> Scene { + let nf = NodeFactory::new(); + let mut repository = NodeRepository::new(); + let n = 5; // number of nesting levels + + // Create innermost rectangle + let mut rect = nf.create_rectangle_node(); + rect.base.name = "Inner Rect".to_string(); + rect.size = Size { + width: 100.0, + height: 100.0, + }; + rect.fill = Paint::Solid(SolidPaint { + color: Color(255, 0, 0, 255), + opacity: 1.0, + }); + let mut current_id = rect.base.id.clone(); + repository.insert(Node::Rectangle(rect)); + + // Create nested structure + for i in 0..n { + if i % 2 == 0 { + // Create group with rotation transform + let mut group = nf.create_group_node(); + group.base.name = format!("Group {}", i); + group.transform = AffineTransform::new( + 50.0 * (i as f32 + 1.0), // x offset + 50.0 * (i as f32 + 1.0), // y offset + 0.0, + ); + + // Add a rectangle to the group + let mut group_rect = nf.create_rectangle_node(); + group_rect.base.name = format!("Group {} Rect", i); + group_rect.size = Size { + width: 100.0, + height: 100.0, + }; + group_rect.fill = Paint::Solid(SolidPaint { + color: Color(0, 255, 0, 255), // Green + opacity: 1.0, + }); + let group_rect_id = group_rect.base.id.clone(); + repository.insert(Node::Rectangle(group_rect)); + + group.children = vec![current_id, group_rect_id]; + current_id = group.base.id.clone(); + repository.insert(Node::Group(group)); + } else { + // Create container with scale transform + let mut container = nf.create_container_node(); + container.base.name = format!("Container {}", i); + container.transform = AffineTransform::new( + -30.0 * (i as f32 + 1.0), // x offset + -30.0 * (i as f32 + 1.0), // y offset + 0.0, + ); + + // Add a rectangle to the container + let mut container_rect = nf.create_rectangle_node(); + container_rect.base.name = format!("Container {} Rect", i); + container_rect.size = Size { + width: 100.0, + height: 100.0, + }; + container_rect.fill = Paint::Solid(SolidPaint { + color: Color(0, 0, 255, 255), // Blue + opacity: 1.0, + }); + let container_rect_id = container_rect.base.id.clone(); + repository.insert(Node::Rectangle(container_rect)); + + container.children = vec![current_id, container_rect_id]; + current_id = container.base.id.clone(); + repository.insert(Node::Container(container)); + } + } + + Scene { + id: "nested".to_string(), + name: "Nested Demo".to_string(), + transform: AffineTransform::identity(), + children: vec![current_id], + nodes: repository, + background_color: Some(Color(250, 250, 250, 255)), + } +} + +#[tokio::main] +async fn main() { + let scene = demo_nested().await; + + window::run_demo_window(scene).await; +} diff --git a/crates/grida-canvas/examples/paint.rs b/crates/grida-canvas/examples/paint.rs new file mode 100644 index 0000000000..594127f283 --- /dev/null +++ b/crates/grida-canvas/examples/paint.rs @@ -0,0 +1,250 @@ +use cg::node::factory::NodeFactory; +use cg::node::repository::NodeRepository; +use cg::node::schema::*; +use cg::window; +use math2::transform::AffineTransform; + +async fn demo_paints() -> Scene { + let nf = NodeFactory::new(); + let mut repository = NodeRepository::new(); + + // Create a root container node + let mut root_container_node = nf.create_container_node(); + root_container_node.base.name = "Root Container".to_string(); + root_container_node.size = Size { + width: 1080.0, + height: 1080.0, + }; + + let mut all_shape_ids = Vec::new(); + let spacing = 100.0; + let start_x = 50.0; + let base_size = 80.0; + let items_per_row = 10; + + // Solid Colors Row + for i in 0..items_per_row { + let mut rect = nf.create_rectangle_node(); + rect.base.name = format!("Solid Color {}", i + 1); + rect.transform = AffineTransform::new(start_x + spacing * i as f32, 100.0, 0.0); + rect.size = Size { + width: base_size, + height: base_size, + }; + rect.corner_radius = RectangularCornerRadius::all(8.0); + rect.fill = Paint::Solid(SolidPaint { + color: Color( + 255 - (i * 25) as u8, + 100 + (i * 15) as u8, + 50 + (i * 20) as u8, + 255, + ), + opacity: 1.0, + }); + all_shape_ids.push(rect.base.id.clone()); + repository.insert(Node::Rectangle(rect)); + } + + // Linear Gradient Row + for i in 0..items_per_row { + let mut rect = nf.create_rectangle_node(); + rect.base.name = format!("Linear Gradient {}", i + 1); + rect.transform = AffineTransform::new(start_x + spacing * i as f32, 200.0, 0.0); + rect.size = Size { + width: base_size, + height: base_size, + }; + rect.corner_radius = RectangularCornerRadius::all(8.0); + + // Create a linear gradient that changes angle based on index + let angle = (i as f32 * 36.0) * std::f32::consts::PI / 180.0; // 0 to 360 degrees + let transform = AffineTransform::new(0.0, 0.0, angle); + + rect.fill = Paint::LinearGradient(LinearGradientPaint { + transform, + stops: vec![ + GradientStop { + offset: 0.0, + color: Color(255, 100, 100, 255), + }, + GradientStop { + offset: 1.0, + color: Color(100, 100, 255, 255), + }, + ], + opacity: 1.0, + }); + all_shape_ids.push(rect.base.id.clone()); + repository.insert(Node::Rectangle(rect)); + } + + // Radial Gradient Row + for i in 0..items_per_row { + let mut rect = nf.create_rectangle_node(); + rect.base.name = format!("Radial Gradient {}", i + 1); + rect.transform = AffineTransform::new(start_x + spacing * i as f32, 300.0, 0.0); + rect.size = Size { + width: base_size, + height: base_size, + }; + rect.corner_radius = RectangularCornerRadius::all(8.0); + + // Create a radial gradient with varying center positions + let center_x = 0.2 + (i as f32 * 0.06); // 0.2 to 0.8 + let center_y = 0.2 + (i as f32 * 0.06); // 0.2 to 0.8 + let transform = AffineTransform::new(center_x * base_size, center_y * base_size, 0.0); + + rect.fill = Paint::RadialGradient(RadialGradientPaint { + transform, + stops: vec![ + GradientStop { + offset: 0.0, + color: Color(255, 255, 100, 255), + }, + GradientStop { + offset: 1.0, + color: Color(100, 255, 100, 255), + }, + ], + opacity: 1.0, + }); + all_shape_ids.push(rect.base.id.clone()); + repository.insert(Node::Rectangle(rect)); + } + + // Stroke Solid Colors Row + for i in 0..items_per_row { + let mut rect = nf.create_rectangle_node(); + rect.base.name = format!("Stroke Solid Color {}", i + 1); + rect.transform = AffineTransform::new(start_x + spacing * i as f32, 400.0, 0.0); + rect.size = Size { + width: base_size, + height: base_size, + }; + rect.corner_radius = RectangularCornerRadius::all(8.0); + + // No fill + rect.fill = Paint::Solid(SolidPaint { + color: Color(0, 0, 0, 0), // Transparent + opacity: 1.0, + }); + + // Solid color stroke with varying colors + rect.stroke = Paint::Solid(SolidPaint { + color: Color( + 255 - (i * 25) as u8, + 100 + (i * 15) as u8, + 50 + (i * 20) as u8, + 255, + ), + opacity: 1.0, + }); + rect.stroke_width = 4.0; // Consistent stroke width + + all_shape_ids.push(rect.base.id.clone()); + repository.insert(Node::Rectangle(rect)); + } + + // Stroke Linear Gradient Row + for i in 0..items_per_row { + let mut rect = nf.create_rectangle_node(); + rect.base.name = format!("Stroke Linear Gradient {}", i + 1); + rect.transform = AffineTransform::new(start_x + spacing * i as f32, 500.0, 0.0); + rect.size = Size { + width: base_size, + height: base_size, + }; + rect.corner_radius = RectangularCornerRadius::all(8.0); + + // No fill + rect.fill = Paint::Solid(SolidPaint { + color: Color(0, 0, 0, 0), // Transparent + opacity: 1.0, + }); + + // Create a linear gradient that changes angle based on index + let angle = (i as f32 * 36.0) * std::f32::consts::PI / 180.0; // 0 to 360 degrees + let transform = AffineTransform::new(0.0, 0.0, angle); + + rect.stroke = Paint::LinearGradient(LinearGradientPaint { + transform, + stops: vec![ + GradientStop { + offset: 0.0, + color: Color(255, 100, 100, 255), + }, + GradientStop { + offset: 1.0, + color: Color(100, 100, 255, 255), + }, + ], + opacity: 1.0, + }); + rect.stroke_width = 4.0; // Consistent stroke width + + all_shape_ids.push(rect.base.id.clone()); + repository.insert(Node::Rectangle(rect)); + } + + // Stroke Radial Gradient Row + for i in 0..items_per_row { + let mut rect = nf.create_rectangle_node(); + rect.base.name = format!("Stroke Radial Gradient {}", i + 1); + rect.transform = AffineTransform::new(start_x + spacing * i as f32, 600.0, 0.0); + rect.size = Size { + width: base_size, + height: base_size, + }; + rect.corner_radius = RectangularCornerRadius::all(8.0); + + // No fill + rect.fill = Paint::Solid(SolidPaint { + color: Color(0, 0, 0, 0), // Transparent + opacity: 1.0, + }); + + // Create a radial gradient with varying center positions + let center_x = 0.2 + (i as f32 * 0.06); // 0.2 to 0.8 + let center_y = 0.2 + (i as f32 * 0.06); // 0.2 to 0.8 + let transform = AffineTransform::new(center_x * base_size, center_y * base_size, 0.0); + + rect.stroke = Paint::RadialGradient(RadialGradientPaint { + transform, + stops: vec![ + GradientStop { + offset: 0.0, + color: Color(255, 255, 100, 255), + }, + GradientStop { + offset: 1.0, + color: Color(100, 255, 100, 255), + }, + ], + opacity: 1.0, + }); + rect.stroke_width = 4.0; // Consistent stroke width + + all_shape_ids.push(rect.base.id.clone()); + repository.insert(Node::Rectangle(rect)); + } + + // Set up the root container + root_container_node.children.extend(all_shape_ids); + let root_container_id = root_container_node.base.id.clone(); + repository.insert(Node::Container(root_container_node)); + + Scene { + id: "scene".to_string(), + name: "Paints Demo".to_string(), + transform: AffineTransform::identity(), + children: vec![root_container_id], + nodes: repository, + background_color: Some(Color(250, 250, 250, 255)), + } +} + +#[tokio::main] +async fn main() { + let scene = demo_paints().await; + window::run_demo_window(scene).await; +} diff --git a/crates/grida-canvas/examples/shapes.rs b/crates/grida-canvas/examples/shapes.rs new file mode 100644 index 0000000000..ddb55a8e33 --- /dev/null +++ b/crates/grida-canvas/examples/shapes.rs @@ -0,0 +1,200 @@ +use cg::node::factory::NodeFactory; +use cg::node::repository::NodeRepository; +use cg::node::schema::*; +use cg::window; +use math2::transform::AffineTransform; + +async fn demo_shapes() -> Scene { + let nf = NodeFactory::new(); + + // Create a root container node + let mut root_container_node = nf.create_container_node(); + root_container_node.base.name = "Root Container".to_string(); + root_container_node.size = Size { + width: 1080.0, + height: 1080.0, + }; + + let mut repository = NodeRepository::new(); + + let mut all_shape_ids = Vec::new(); + let spacing = 100.0; + let start_x = 50.0; + let base_size = 80.0; + let items_per_row = 10; + + // Rectangle Row - demonstrating corner radius variations + for i in 0..items_per_row { + let mut rect = nf.create_rectangle_node(); + rect.base.name = format!("Rectangle {}", i + 1); + rect.transform = AffineTransform::new(start_x + spacing * i as f32, 100.0, 0.0); + rect.size = Size { + width: base_size, + height: base_size, + }; + rect.corner_radius = RectangularCornerRadius::all(0.0 + (i as f32 * 8.0)); // 0 to 72 + rect.fill = Paint::Solid(SolidPaint { + color: Color( + 200 - (i * 20) as u8, + 200 - (i * 20) as u8, + 200 - (i * 20) as u8, + 255, + ), // Fading gray + opacity: 1.0, + }); + all_shape_ids.push(rect.base.id.clone()); + repository.insert(Node::Rectangle(rect)); + } + + // Ellipse Row - demonstrating width/height ratio variations + for i in 0..items_per_row { + let mut ellipse = nf.create_ellipse_node(); + ellipse.base.name = format!("Ellipse {}", i + 1); + ellipse.transform = AffineTransform::new(start_x + spacing * i as f32, 200.0, 0.0); + ellipse.size = Size { + width: base_size * (1.0 + (i as f32 * 0.1)), // 1.0x to 1.9x width + height: base_size, + }; + ellipse.fill = Paint::Solid(SolidPaint { + color: Color( + 200 - (i * 20) as u8, + 200 - (i * 20) as u8, + 200 - (i * 20) as u8, + 255, + ), // Fading gray + opacity: 1.0, + }); + all_shape_ids.push(ellipse.base.id.clone()); + repository.insert(Node::Ellipse(ellipse)); + } + + // Polygon Row - demonstrating point count variations + for i in 0..items_per_row { + let point_count = 3 + i; // 3 to 12 points + let points = (0..point_count) + .map(|j| { + let angle = std::f32::consts::PI * 2.0 * (j as f32) / (point_count as f32) + - std::f32::consts::FRAC_PI_2; + let radius = base_size / 2.0; + let x = radius * angle.cos(); + let y = radius * angle.sin(); + Point { x, y } + }) + .collect::>(); + + let mut polygon = nf.create_polygon_node(); + polygon.base.name = format!("Polygon {}", i + 1); + polygon.transform = AffineTransform::new(start_x + spacing * i as f32, 300.0, 0.0); + polygon.points = points; + polygon.corner_radius = 16.0; + polygon.fill = Paint::Solid(SolidPaint { + color: Color( + 200 - (i * 20) as u8, + 200 - (i * 20) as u8, + 200 - (i * 20) as u8, + 255, + ), // Fading gray + opacity: 1.0, + }); + all_shape_ids.push(polygon.base.id.clone()); + repository.insert(Node::Polygon(polygon)); + } + + // Regular Polygon Row - demonstrating point count variations + for i in 0..items_per_row { + let mut regular_polygon = nf.create_regular_polygon_node(); + regular_polygon.base.name = format!("Regular Polygon {}", i + 1); + regular_polygon.transform = AffineTransform::new(start_x + spacing * i as f32, 400.0, 0.0); + regular_polygon.size = Size { + width: base_size, + height: base_size, + }; + regular_polygon.point_count = 3 + i; // 3 to 12 points + regular_polygon.fill = Paint::Solid(SolidPaint { + color: Color( + 200 - (i * 20) as u8, + 200 - (i * 20) as u8, + 200 - (i * 20) as u8, + 255, + ), // Fading gray + opacity: 1.0, + }); + all_shape_ids.push(regular_polygon.base.id.clone()); + repository.insert(Node::RegularPolygon(regular_polygon)); + } + + // Path Row - demonstrating different path patterns + let path_data = vec![ + "M50,0 L61,35 L98,35 L68,57 L79,91 L50,71 L21,91 L32,57 L2,35 L39,35 Z", // 5-point star + "M50,0 L100,50 L0,50 Z", // Triangle + "M0,0 L100,0 L100,100 L0,100 Z", // Square + "M50,0 L100,50 L50,100 L0,50 Z", // Diamond + "M0,0 L100,0 L100,100 L0,100 L0,0 M20,20 L80,20 L80,80 L20,80 Z", // Square with hole + "M50,0 A50,50 0 0 1 100,50 A50,50 0 0 1 50,100 A50,50 0 0 1 0,50 A50,50 0 0 1 50,0 Z", // Circle + "M0,50 L50,0 L100,50 L50,100 Z", // Diamond + "M0,0 L100,0 L50,100 Z", // Triangle + "M0,0 L100,0 L100,100 L0,100 Z M20,20 L80,20 L80,80 L20,80 Z", // Square with hole + "M50,0 A50,50 0 0 1 100,50 A50,50 0 0 1 50,100 A50,50 0 0 1 0,50 A50,50 0 0 1 50,0 Z", // Circle + ]; + for (i, data) in path_data.iter().enumerate() { + let mut path = nf.create_path_node(); + path.base.name = format!("Path {}", i + 1); + path.transform = AffineTransform::new(start_x + spacing * i as f32, 500.0, 0.0); + path.data = data.to_string(); + path.fill = Paint::Solid(SolidPaint { + color: Color( + 200 - (i * 20) as u8, + 200 - (i * 20) as u8, + 200 - (i * 20) as u8, + 255, + ), // Fading gray + opacity: 1.0, + }); + all_shape_ids.push(path.base.id.clone()); + repository.insert(Node::Path(path)); + } + + // Star Polygon Row - demonstrating different point counts and inner radius variations + for i in 0..items_per_row { + let mut star = nf.create_regular_star_polygon_node(); + star.base.name = format!("Star Polygon {}", i + 1); + star.transform = AffineTransform::new(start_x + spacing * i as f32, 600.0, 0.0); + star.size = Size { + width: base_size, + height: base_size, + }; + star.point_count = 3 + i; // 3 to 12 points + star.inner_radius = 0.7 - (i as f32 * 0.05); // 0.3 to 0.75 inner radius + star.fill = Paint::Solid(SolidPaint { + color: Color( + 200 - (i * 20) as u8, + 200 - (i * 20) as u8, + 200 - (i * 20) as u8, + 255, + ), // Fading gray + opacity: 1.0, + }); + all_shape_ids.push(star.base.id.clone()); + repository.insert(Node::RegularStarPolygon(star)); + } + + // Set up the root container + root_container_node.children.extend(all_shape_ids); + let root_container_id = root_container_node.base.id.clone(); + repository.insert(Node::Container(root_container_node)); + + Scene { + id: "scene".to_string(), + name: "Shapes Demo".to_string(), + transform: AffineTransform::identity(), + children: vec![root_container_id], + nodes: repository, + background_color: Some(Color(250, 250, 250, 255)), + } +} + +#[tokio::main] +async fn main() { + let scene = demo_shapes().await; + window::run_demo_window(scene).await; +} diff --git a/crates/grida-canvas/examples/srcset.rs b/crates/grida-canvas/examples/srcset.rs new file mode 100644 index 0000000000..50af34dc2e --- /dev/null +++ b/crates/grida-canvas/examples/srcset.rs @@ -0,0 +1,177 @@ +use skia_safe::canvas::SrcRectConstraint; +use skia_safe::{Canvas, Color, Image, Paint, Rect, Surface}; +use std::path::Path; +use std::time::{Duration, Instant}; + +struct ImageBenchmark { + original: Image, + scaled_versions: Vec<(f32, Image)>, // (scale_factor, image) +} + +impl ImageBenchmark { + fn new(image_path: &str) -> Self { + // Load the original image + let data = std::fs::read(image_path).expect("Failed to read image file"); + let original = + Image::from_encoded(skia_safe::Data::new_copy(&data)).expect("Failed to decode image"); + + // Create scaled versions + let mut scaled_versions = Vec::new(); + let scales = [1.0, 0.5, 0.25, 0.125]; // Added 0.125 scale + + for scale in scales { + let width = (original.width() as f32 * scale) as i32; + let height = (original.height() as f32 * scale) as i32; + + // Create a surface for the scaled image + let mut surface = + Surface::new_raster_n32_premul((width, height)).expect("Failed to create surface"); + let canvas = surface.canvas(); + + // Clear the surface + canvas.clear(Color::WHITE); + + // Draw the original image scaled down + let mut paint = Paint::default(); + paint.set_anti_alias(true); + + let src_rect = Rect::new(0.0, 0.0, original.width() as f32, original.height() as f32); + let dst_rect = Rect::new(0.0, 0.0, width as f32, height as f32); + canvas.draw_image_rect( + &original, + Some((&src_rect, SrcRectConstraint::Fast)), + dst_rect, + &paint, + ); + + // Get the scaled image + let scaled_image = surface.image_snapshot(); + scaled_versions.push((scale, scaled_image)); + } + + Self { + original, + scaled_versions, + } + } + + fn benchmark_rendering( + &self, + iterations: usize, + images_per_iteration: usize, + ) -> Vec<(f32, Duration, Duration)> { + let mut results = Vec::new(); + + // Create a surface for rendering + let mut surface = + Surface::new_raster_n32_premul((800, 600)).expect("Failed to create surface"); + let canvas = surface.canvas(); + + // Calculate grid layout + let grid_size = (images_per_iteration as f32).sqrt().ceil() as i32; + let image_width = 800.0 / grid_size as f32; + let image_height = 600.0 / grid_size as f32; + + // Benchmark each scaled version + for (scale, image) in &self.scaled_versions { + let mut total_time_fast = Duration::new(0, 0); + let mut total_time_strict = Duration::new(0, 0); + + for _ in 0..iterations { + // Test with Fast constraint + canvas.clear(Color::WHITE); + let start = Instant::now(); + for i in 0..images_per_iteration { + let row = (i as i32) / grid_size; + let col = (i as i32) % grid_size; + + let x = col as f32 * image_width; + let y = row as f32 * image_height; + + let mut paint = Paint::default(); + paint.set_anti_alias(true); + + let src_rect = Rect::new(0.0, 0.0, image.width() as f32, image.height() as f32); + let dst_rect = Rect::new(x, y, x + image_width, y + image_height); + canvas.draw_image_rect( + image, + Some((&src_rect, SrcRectConstraint::Fast)), + dst_rect, + &paint, + ); + } + total_time_fast += start.elapsed(); + + // Test with Strict constraint + canvas.clear(Color::WHITE); + let start = Instant::now(); + for i in 0..images_per_iteration { + let row = (i as i32) / grid_size; + let col = (i as i32) % grid_size; + + let x = col as f32 * image_width; + let y = row as f32 * image_height; + + let mut paint = Paint::default(); + paint.set_anti_alias(true); + + let src_rect = Rect::new(0.0, 0.0, image.width() as f32, image.height() as f32); + let dst_rect = Rect::new(x, y, x + image_width, y + image_height); + canvas.draw_image_rect( + image, + Some((&src_rect, SrcRectConstraint::Strict)), + dst_rect, + &paint, + ); + } + total_time_strict += start.elapsed(); + } + + let avg_time_fast = total_time_fast / iterations as u32; + let avg_time_strict = total_time_strict / iterations as u32; + results.push((*scale, avg_time_fast, avg_time_strict)); + } + + results + } +} + +fn main() { + // Check if image exists + let image_path = "resources/4k.jpg"; + if !Path::new(image_path).exists() { + println!( + "Error: {} not found. Please place a 4K image named '4k.jpg' in the resources directory.", + image_path + ); + return; + } + + println!("Loading and preparing images..."); + let benchmark = ImageBenchmark::new(image_path); + + let iterations = 1000; + let images_per_iteration = 16; // Render 16 images per iteration in a 4x4 grid + + println!( + "\nRunning benchmark ({} iterations, {} images per iteration)...", + iterations, images_per_iteration + ); + let results = benchmark.benchmark_rendering(iterations, images_per_iteration); + + println!("\nResults:"); + println!( + "Scale | Fast Quality (ms) | Strict Quality (ms) | Fast/Image (ms) | Strict/Image (ms)" + ); + println!("------|-----------------|-------------------|----------------|------------------"); + for (scale, fast_time, strict_time) in results { + let fast_ms = fast_time.as_secs_f64() * 1000.0; + let strict_ms = strict_time.as_secs_f64() * 1000.0; + let fast_per_image = fast_ms / images_per_iteration as f64; + let strict_per_image = strict_ms / images_per_iteration as f64; + println!( + "{:.3}x | {:.2} | {:.2} | {:.2} | {:.2}", + scale, fast_ms, strict_ms, fast_per_image, strict_per_image + ); + } +} diff --git a/crates/grida-canvas/examples/stroke.rs b/crates/grida-canvas/examples/stroke.rs new file mode 100644 index 0000000000..2e61b9395d --- /dev/null +++ b/crates/grida-canvas/examples/stroke.rs @@ -0,0 +1,397 @@ +use cg::node::factory::NodeFactory; +use cg::node::repository::NodeRepository; +use cg::node::schema::*; +use cg::window; +use math2::transform::AffineTransform; + +async fn demo_strokes() -> Scene { + let nf = NodeFactory::new(); + + // Create a root container node + let mut root_container_node = nf.create_container_node(); + root_container_node.base.name = "Root Container".to_string(); + root_container_node.size = Size { + width: 1000.0, + height: 1000.0, + }; + + let mut repository = NodeRepository::new(); + + let mut all_shape_ids = Vec::new(); + let spacing = 120.0; + let start_x = 50.0; + let base_size = 100.0; + let items_per_row = 8; + + // Stroke Alignment Demo Row + for i in 0..3 { + let mut rect = nf.create_rectangle_node(); + rect.base.name = format!("Stroke Alignment {}", i + 1); + rect.transform = AffineTransform::new(start_x + spacing * i as f32, 100.0, 0.0); + rect.size = Size { + width: base_size, + height: base_size, + }; + rect.corner_radius = RectangularCornerRadius::all(8.0); + + // No fill + rect.fill = Paint::Solid(SolidPaint { + color: Color(0, 0, 0, 0), // Transparent + opacity: 1.0, + }); + + // Solid color stroke + rect.stroke = Paint::Solid(SolidPaint { + color: Color(0, 0, 0, 255), // Black stroke + opacity: 1.0, + }); + rect.stroke_width = 8.0; // Thick stroke to make alignment visible + + // Set different alignments + rect.stroke_align = match i { + 0 => StrokeAlign::Inside, + 1 => StrokeAlign::Center, + 2 => StrokeAlign::Outside, + _ => unreachable!(), + }; + + all_shape_ids.push(rect.base.id.clone()); + repository.insert(Node::Rectangle(rect)); + } + + // Stroke Width Demo Row + for i in 0..items_per_row { + let mut rect = nf.create_rectangle_node(); + rect.base.name = format!("Stroke Width {}", i + 1); + rect.transform = AffineTransform::new(start_x + spacing * i as f32, 250.0, 0.0); + rect.size = Size { + width: base_size, + height: base_size, + }; + rect.corner_radius = RectangularCornerRadius::all(8.0); + + // No fill + rect.fill = Paint::Solid(SolidPaint { + color: Color(0, 0, 0, 0), // Transparent + opacity: 1.0, + }); + + // Solid color stroke + rect.stroke = Paint::Solid(SolidPaint { + color: Color(0, 0, 0, 255), // Black stroke + opacity: 1.0, + }); + rect.stroke_width = (i + 1) as f32 * 2.0; // Increasing stroke width + rect.stroke_align = StrokeAlign::Center; + + all_shape_ids.push(rect.base.id.clone()); + repository.insert(Node::Rectangle(rect)); + } + + // Stroke with Different Shapes Row + { + // Rectangle + let mut rect = nf.create_rectangle_node(); + rect.base.name = "Rectangle Stroke".to_string(); + rect.transform = AffineTransform::new(start_x, 400.0, 0.0); + rect.size = Size { + width: base_size, + height: base_size, + }; + rect.corner_radius = RectangularCornerRadius::all(8.0); + rect.fill = Paint::Solid(SolidPaint { + color: Color(0, 0, 0, 0), + opacity: 1.0, + }); + rect.stroke = Paint::Solid(SolidPaint { + color: Color(0, 0, 0, 255), + opacity: 1.0, + }); + rect.stroke_width = 4.0; + all_shape_ids.push(rect.base.id.clone()); + repository.insert(Node::Rectangle(rect)); + + // Ellipse + let mut ellipse = nf.create_ellipse_node(); + ellipse.base.name = "Ellipse Stroke".to_string(); + ellipse.transform = AffineTransform::new(start_x + spacing, 400.0, 0.0); + ellipse.size = Size { + width: base_size, + height: base_size, + }; + ellipse.fill = Paint::Solid(SolidPaint { + color: Color(0, 0, 0, 0), + opacity: 1.0, + }); + ellipse.stroke = Paint::Solid(SolidPaint { + color: Color(0, 0, 0, 255), + opacity: 1.0, + }); + ellipse.stroke_width = 4.0; + all_shape_ids.push(ellipse.base.id.clone()); + repository.insert(Node::Ellipse(ellipse)); + + // Regular Polygon (Hexagon) + let mut polygon = nf.create_regular_polygon_node(); + polygon.base.name = "Hexagon Stroke".to_string(); + polygon.transform = AffineTransform::new(start_x + spacing * 2.0, 400.0, 0.0); + polygon.size = Size { + width: base_size, + height: base_size, + }; + polygon.point_count = 6; + polygon.fill = Paint::Solid(SolidPaint { + color: Color(0, 0, 0, 0), + opacity: 1.0, + }); + polygon.stroke = Paint::Solid(SolidPaint { + color: Color(0, 0, 0, 255), + opacity: 1.0, + }); + polygon.stroke_width = 4.0; + all_shape_ids.push(polygon.base.id.clone()); + repository.insert(Node::RegularPolygon(polygon)); + + // Star + let mut star = nf.create_regular_star_polygon_node(); + star.base.name = "Star Stroke".to_string(); + star.transform = AffineTransform::new(start_x + spacing * 3.0, 400.0, 0.0); + star.size = Size { + width: base_size, + height: base_size, + }; + star.point_count = 5; + star.inner_radius = 0.4; + star.fill = Paint::Solid(SolidPaint { + color: Color(0, 0, 0, 0), + opacity: 1.0, + }); + star.stroke = Paint::Solid(SolidPaint { + color: Color(0, 0, 0, 255), + opacity: 1.0, + }); + star.stroke_width = 4.0; + all_shape_ids.push(star.base.id.clone()); + repository.insert(Node::RegularStarPolygon(star)); + } + + // Stroke with Effects Row + for i in 0..3 { + let mut rect = nf.create_rectangle_node(); + rect.base.name = format!("Stroke with Effect {}", i + 1); + rect.transform = AffineTransform::new(start_x + spacing * i as f32, 550.0, 0.0); + rect.size = Size { + width: base_size, + height: base_size, + }; + rect.corner_radius = RectangularCornerRadius::all(8.0); + + // No fill + rect.fill = Paint::Solid(SolidPaint { + color: Color(0, 0, 0, 0), + opacity: 1.0, + }); + + // Solid color stroke + rect.stroke = Paint::Solid(SolidPaint { + color: Color(0, 0, 0, 255), + opacity: 1.0, + }); + rect.stroke_width = 4.0; + + // Add different effects + rect.effect = match i { + 0 => Some(FilterEffect::DropShadow(FeDropShadow { + dx: 4.0, + dy: 4.0, + blur: 4.0, + color: Color(0, 0, 0, 128), + })), + 1 => Some(FilterEffect::GaussianBlur(FeGaussianBlur { radius: 2.0 })), + 2 => Some(FilterEffect::BackdropBlur(FeBackdropBlur { radius: 4.0 })), + _ => unreachable!(), + }; + + all_shape_ids.push(rect.base.id.clone()); + repository.insert(Node::Rectangle(rect)); + } + + // Stroke Dash Array Demo Row + for i in 0..4 { + let mut rect = nf.create_rectangle_node(); + rect.base.name = format!("Stroke Dash Array {}", i + 1); + rect.transform = AffineTransform::new(start_x + spacing * i as f32, 700.0, 0.0); + rect.size = Size { + width: base_size, + height: base_size, + }; + rect.corner_radius = RectangularCornerRadius::all(8.0); + + // No fill + rect.fill = Paint::Solid(SolidPaint { + color: Color(0, 0, 0, 0), + opacity: 1.0, + }); + + // Solid color stroke + rect.stroke = Paint::Solid(SolidPaint { + color: Color(0, 0, 0, 255), + opacity: 1.0, + }); + rect.stroke_width = 4.0; + + // Add different dash patterns + rect.stroke_dash_array = match i { + 0 => Some(vec![5.0, 5.0]), // Basic dashed line + 1 => Some(vec![10.0, 5.0]), // Longer dashes + 2 => Some(vec![5.0, 5.0, 1.0, 5.0]), // Dash-dot pattern + 3 => Some(vec![1.0, 1.0]), // Dotted line + _ => unreachable!(), + }; + + all_shape_ids.push(rect.base.id.clone()); + repository.insert(Node::Rectangle(rect)); + } + + // Stroke Paint Types Demo Row + { + // Linear Gradient Stroke + let mut rect = nf.create_rectangle_node(); + rect.base.name = "Linear Gradient Stroke".to_string(); + rect.transform = AffineTransform::new(start_x, 850.0, 0.0); + rect.size = Size { + width: base_size, + height: base_size, + }; + rect.corner_radius = RectangularCornerRadius::all(8.0); + rect.fill = Paint::Solid(SolidPaint { + color: Color(0, 0, 0, 0), + opacity: 1.0, + }); + rect.stroke = Paint::LinearGradient(LinearGradientPaint { + transform: AffineTransform::new(0.0, 0.0, 0.0), + stops: vec![ + GradientStop { + offset: 0.0, + color: Color(255, 0, 0, 255), // Red + }, + GradientStop { + offset: 1.0, + color: Color(0, 0, 255, 255), // Blue + }, + ], + opacity: 1.0, + }); + rect.stroke_width = 8.0; + all_shape_ids.push(rect.base.id.clone()); + repository.insert(Node::Rectangle(rect)); + + // Radial Gradient Stroke + let mut rect = nf.create_rectangle_node(); + rect.base.name = "Radial Gradient Stroke".to_string(); + rect.transform = AffineTransform::new(start_x + spacing, 850.0, 0.0); + rect.size = Size { + width: base_size, + height: base_size, + }; + rect.corner_radius = RectangularCornerRadius::all(8.0); + rect.fill = Paint::Solid(SolidPaint { + color: Color(0, 0, 0, 0), + opacity: 1.0, + }); + rect.stroke = Paint::RadialGradient(RadialGradientPaint { + transform: AffineTransform::new(base_size / 2.0, base_size / 2.0, 0.0), + stops: vec![ + GradientStop { + offset: 0.0, + color: Color(255, 255, 0, 255), // Yellow + }, + GradientStop { + offset: 1.0, + color: Color(255, 0, 255, 255), // Magenta + }, + ], + opacity: 1.0, + }); + rect.stroke_width = 8.0; + all_shape_ids.push(rect.base.id.clone()); + repository.insert(Node::Rectangle(rect)); + + // Conic Gradient Stroke + let mut rect = nf.create_rectangle_node(); + rect.base.name = "Conic Gradient Stroke".to_string(); + rect.transform = AffineTransform::new(start_x + spacing * 2.0, 850.0, 0.0); + rect.size = Size { + width: base_size, + height: base_size, + }; + rect.corner_radius = RectangularCornerRadius::all(8.0); + rect.fill = Paint::Solid(SolidPaint { + color: Color(0, 0, 0, 0), + opacity: 1.0, + }); + rect.stroke = Paint::RadialGradient(RadialGradientPaint { + transform: AffineTransform::new(base_size / 2.0, base_size / 2.0, 0.0), + stops: vec![ + GradientStop { + offset: 0.0, + color: Color(0, 255, 0, 255), // Green + }, + GradientStop { + offset: 0.5, + color: Color(0, 255, 255, 255), // Cyan + }, + GradientStop { + offset: 1.0, + color: Color(0, 255, 0, 255), // Green + }, + ], + opacity: 1.0, + }); + rect.stroke_width = 8.0; + all_shape_ids.push(rect.base.id.clone()); + repository.insert(Node::Rectangle(rect)); + + // Multi-color Solid Stroke + let mut rect = nf.create_rectangle_node(); + rect.base.name = "Multi-color Stroke".to_string(); + rect.transform = AffineTransform::new(start_x + spacing * 3.0, 850.0, 0.0); + rect.size = Size { + width: base_size, + height: base_size, + }; + rect.corner_radius = RectangularCornerRadius::all(8.0); + rect.fill = Paint::Solid(SolidPaint { + color: Color(0, 0, 0, 0), + opacity: 1.0, + }); + rect.stroke = Paint::Solid(SolidPaint { + color: Color(255, 128, 0, 255), // Orange + opacity: 1.0, + }); + rect.stroke_width = 8.0; + rect.stroke_dash_array = Some(vec![20.0, 10.0, 5.0, 10.0]); // Complex dash pattern + all_shape_ids.push(rect.base.id.clone()); + repository.insert(Node::Rectangle(rect)); + } + + // Set up the root container + root_container_node.children.extend(all_shape_ids); + let root_container_id = root_container_node.base.id.clone(); + repository.insert(Node::Container(root_container_node)); + + Scene { + id: "scene".to_string(), + name: "Strokes Demo".to_string(), + transform: AffineTransform::identity(), + children: vec![root_container_id], + nodes: repository, + background_color: Some(Color(250, 250, 250, 255)), + } +} + +#[tokio::main] +async fn main() { + let scene = demo_strokes().await; + window::run_demo_window(scene).await; +} diff --git a/crates/grida-canvas/examples/texts.rs b/crates/grida-canvas/examples/texts.rs new file mode 100644 index 0000000000..30860d01d2 --- /dev/null +++ b/crates/grida-canvas/examples/texts.rs @@ -0,0 +1,182 @@ +use cg::font_loader::FontLoader; +use cg::node::factory::NodeFactory; +use cg::node::repository::NodeRepository; +use cg::node::schema::*; +use cg::window; +use math2::transform::AffineTransform; + +const LOREM: &str = r#" +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum sed leo quis orci porta auctor eget nec dui. Nullam egestas tempus sapien quis venenatis. Nullam placerat, elit eu aliquet luctus, risus elit sodales elit, eu iaculis ante lacus nec lacus. Vestibulum eget dolor at orci iaculis malesuada. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Quisque cursus tincidunt accumsan. In hac habitasse platea dictumst. Etiam ultricies laoreet ipsum id pulvinar. Aenean fermentum gravida nisi, et congue lectus interdum et. Cras pellentesque scelerisque quam, ut mollis ligula aliquet ut. + +Maecenas convallis nisl non porta consectetur. Nulla scelerisque urna ut massa condimentum hendrerit. Cras eu orci malesuada, ornare est ut, viverra libero. Praesent at turpis ultrices, eleifend leo id, gravida lorem. Aenean eu nunc ac orci aliquam ultricies. Suspendisse mi est, convallis et tincidunt nec, iaculis nec metus. Vestibulum vitae metus nisi. Etiam felis mauris, ullamcorper sed aliquet eu, porttitor eu magna. Vestibulum vel mattis purus, vitae semper tortor. Etiam vestibulum ex id risus viverra vulputate. Aenean euismod lectus tortor, vitae interdum erat blandit sed. Vestibulum accumsan massa vehicula tellus efficitur vehicula. Donec accumsan eget purus sed condimentum. Nunc tempor imperdiet odio a molestie. Phasellus velit nulla, volutpat ac ipsum id, iaculis pretium ipsum. + +Cras ac justo iaculis, sollicitudin nisl vel, maximus turpis. Nulla sed nunc elit. Maecenas ultricies auctor mi quis semper. Suspendisse eget rhoncus enim. Morbi tincidunt, urna sed dapibus consequat, ex lorem scelerisque risus, vel auctor libero dui eu diam. Aliquam a rutrum risus. Nunc facilisis, est a rutrum commodo, eros ipsum pulvinar enim, sit amet elementum est dolor quis mi. Nam aliquet, massa eget vestibulum tincidunt, tortor leo dictum arcu, quis eleifend felis ligula in odio. Nullam pharetra mauris ac tortor pharetra ultricies. Aenean in dictum lorem, eu vestibulum libero. Praesent efficitur pretium magna, nec tristique urna condimentum vitae. Aliquam eu nibh quis urna rhoncus porta. Duis lacus leo, tempus ut urna sit amet, dignissim consectetur lorem. Duis luctus scelerisque ultricies. Quisque pharetra feugiat metus in tempor. +"#; + +const LOREM_SHORT: &str = r#"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum sed leo quis orci porta auctor eget nec dui. Nullam egestas tempus sapien quis venenatis. Nullam placerat, elit eu aliquet luctus, risus elit sodales elit, eu iaculis ante lacus nec lacus. Vestibulum eget dolor at orci iaculis malesuada. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Quisque cursus tincidunt accumsan. In hac habitasse platea dictumst. Etiam ultricies laoreet ipsum id pulvinar. Aenean fermentum gravida nisi, et congue lectus interdum et. Cras pellentesque scelerisque quam, ut mollis ligula aliquet ut."#; + +async fn demo_texts() -> Scene { + let nf = NodeFactory::new(); + + // Create a single word text span + let mut word_text_node = nf.create_text_span_node(); + word_text_node.base.name = "Word Text".to_string(); + word_text_node.transform = AffineTransform::new(50.0, 50.0, 0.0); + word_text_node.size = Size { + width: 400.0, + height: 100.0, + }; + word_text_node.text = "Grida Canvas".to_string(); + word_text_node.text_style = TextStyle { + text_decoration: TextDecoration::None, + font_family: "Arial".to_string(), + font_size: 48.0, + italic: false, // TODO: add italic to text style + font_weight: FontWeight::new(700), // Bold + letter_spacing: None, + line_height: None, + text_transform: TextTransform::Uppercase, + }; + word_text_node.stroke = Some(Paint::Solid(SolidPaint { + color: Color(255, 255, 255, 255), + opacity: 1.0, + })); + word_text_node.stroke_width = Some(1.0); + word_text_node.text_align = TextAlign::Left; + word_text_node.text_align_vertical = TextAlignVertical::Top; + + // Create a sentence text span + let mut sentence_text_node = nf.create_text_span_node(); + sentence_text_node.base.name = "Sentence Text".to_string(); + sentence_text_node.transform = AffineTransform::new(50.0, 150.0, 0.0); + sentence_text_node.size = Size { + width: 500.0, + height: 100.0, + }; + sentence_text_node.text = + "Grida Canvas Skia Backend provides accurate rendering of Texts and Text layouts" + .to_string(); + sentence_text_node.text_style = TextStyle { + text_decoration: TextDecoration::Underline, + font_family: "Caveat".to_string(), + font_size: 32.0, + italic: false, // TODO: add italic to text style + font_weight: FontWeight::new(400), // Regular + letter_spacing: None, + line_height: None, + text_transform: TextTransform::None, + }; + sentence_text_node.text_align = TextAlign::Left; + sentence_text_node.text_align_vertical = TextAlignVertical::Center; + + // Create a paragraph text span + let mut paragraph_text_node = nf.create_text_span_node(); + paragraph_text_node.base.name = "Paragraph Text".to_string(); + paragraph_text_node.transform = AffineTransform::new(50.0, 250.0, 0.0); + paragraph_text_node.size = Size { + width: 800.0, + height: 300.0, + }; + paragraph_text_node.text = LOREM.to_string(); + paragraph_text_node.text_style = TextStyle { + text_decoration: TextDecoration::None, + font_family: "Arial".to_string(), + font_size: 16.0, + italic: false, // TODO: add italic to text style + font_weight: FontWeight::new(400), // Regular + letter_spacing: None, + line_height: Some(1.5), // 1.5 line height for better readability + text_transform: TextTransform::None, + }; + paragraph_text_node.text_align = TextAlign::Left; + paragraph_text_node.text_align_vertical = TextAlignVertical::Top; + + // Create a second paragraph text span with different color + let mut second_paragraph_text_node = nf.create_text_span_node(); + second_paragraph_text_node.base.name = "Second Paragraph Text".to_string(); + second_paragraph_text_node.transform = AffineTransform::new(50.0, 800.0, 0.0); + second_paragraph_text_node.size = Size { + width: 800.0, + height: 300.0, + }; + second_paragraph_text_node.text = LOREM_SHORT.to_string(); + second_paragraph_text_node.text_style = TextStyle { + text_decoration: TextDecoration::None, + font_family: "VT323".to_string(), + font_size: 16.0, + italic: false, // TODO: add italic to text style + font_weight: FontWeight::new(400), // Regular + letter_spacing: None, + line_height: Some(1.5), // 1.5 line height for better readability + text_transform: TextTransform::None, + }; + second_paragraph_text_node.text_align = TextAlign::Left; + second_paragraph_text_node.text_align_vertical = TextAlignVertical::Top; + second_paragraph_text_node.fill = Paint::Solid(SolidPaint { + color: Color(70, 130, 180, 255), // Steel blue color + opacity: 1.0, + }); + + // Create a root container node + let mut root_container_node = nf.create_container_node(); + root_container_node.base.name = "Root Container".to_string(); + root_container_node.size = Size { + width: 1080.0, + height: 1080.0, + }; + + // Create a node repository and add all nodes + let mut repository = NodeRepository::new(); + + // Collect all the IDs + let word_text_id = word_text_node.base.id.clone(); + let sentence_text_id = sentence_text_node.base.id.clone(); + let paragraph_text_id = paragraph_text_node.base.id.clone(); + let second_paragraph_text_id = second_paragraph_text_node.base.id.clone(); + + // Add all nodes to the repository + repository.insert(Node::TextSpan(word_text_node)); + repository.insert(Node::TextSpan(sentence_text_node)); + repository.insert(Node::TextSpan(paragraph_text_node)); + repository.insert(Node::TextSpan(second_paragraph_text_node)); + + // Set up the root container with all IDs + root_container_node.children = vec![ + word_text_id, + sentence_text_id, + paragraph_text_id, + second_paragraph_text_id, + ]; + let root_container_id = root_container_node.base.id.clone(); + repository.insert(Node::Container(root_container_node)); + + Scene { + id: "scene".to_string(), + name: "Text Demo".to_string(), + transform: AffineTransform::identity(), + children: vec![root_container_id], + nodes: repository, + background_color: Some(Color(250, 250, 250, 255)), + } +} + +#[tokio::main] +async fn main() { + let scene = demo_texts().await; + let caveat_font_path = "resources/Caveat-VariableFont_wght.ttf".to_string(); + let vt323_font_path = "resources/VT323-Regular.ttf".to_string(); + + window::run_demo_window_with(scene, move |_renderer, _img_tx, font_tx, proxy| { + println!("📝 Loading fonts asynchronously..."); + let caveat_path = caveat_font_path.clone(); + let vt323_path = vt323_font_path.clone(); + tokio::spawn(async move { + let mut loader = FontLoader::new_lifecycle(font_tx, proxy); + loader.load_font("Caveat", &caveat_path).await; + println!("✅ Font loaded: Caveat"); + loader.load_font("VT323", &vt323_path).await; + println!("✅ Font loaded: VT323"); + }); + }) + .await; +} diff --git a/crates/grida-canvas/examples/tiling.rs b/crates/grida-canvas/examples/tiling.rs new file mode 100644 index 0000000000..84ce6eff0f --- /dev/null +++ b/crates/grida-canvas/examples/tiling.rs @@ -0,0 +1,111 @@ +use skia_safe::{Canvas, Color, Image, Paint, Picture, PictureRecorder, Rect, Surface}; +use std::path::Path; + +struct TiledScene { + picture: Picture, + width: f32, + height: f32, +} + +impl TiledScene { + fn new(width: f32, height: f32) -> Self { + // Create a recorder to capture the scene + let mut recorder = PictureRecorder::new(); + let bounds = Rect::new(0.0, 0.0, width, height); + let canvas = recorder.begin_recording(bounds, None); + + // Draw 100 rectangles in a grid + Self::draw_rectangles(canvas); + + // End recording and create the picture + let picture = recorder.finish_recording_as_picture(None).unwrap(); + + Self { + picture, + width, + height, + } + } + + fn draw_rectangles(canvas: &Canvas) { + println!("Drawing 100 rectangles"); + // Draw a grid of rectangles + for i in 0..10 { + for j in 0..10 { + let mut paint = Paint::default(); + // Create a gradient of colors + let r = (i * 25) as u8; + let g = (j * 25) as u8; + let b = ((i + j) * 12) as u8; + paint.set_color(Color::from_argb(255, r, g, b)); + + let x = i as f32 * 100.0; + let y = j as f32 * 100.0; + let rect = Rect::new(x, y, x + 80.0, y + 80.0); + canvas.draw_rect(rect, &paint); + } + } + } + + fn render_to_tiles(&self, tile_size: i32) -> Vec { + let mut tiles = Vec::new(); + let cols = (self.width as i32 + tile_size - 1) / tile_size; + let rows = (self.height as i32 + tile_size - 1) / tile_size; + + for row in 0..rows { + for col in 0..cols { + // Create a surface for this tile + let mut surface = Surface::new_raster_n32_premul((tile_size, tile_size)) + .expect("Failed to create surface for tile"); + let canvas = surface.canvas(); + + // Clear the tile + canvas.clear(Color::from_argb(255, 255, 255, 255)); + + // Calculate the offset for this tile + let offset_x = -(col * tile_size) as f32; + let offset_y = -(row * tile_size) as f32; + + // Apply the offset + canvas.save(); + canvas.translate((offset_x, offset_y)); + + // Draw the picture + canvas.draw_picture(&self.picture, None, None); + + canvas.restore(); + + // Convert the surface to an image + let image = surface.image_snapshot(); + tiles.push(image); + } + } + + tiles + } +} + +fn save_tiles(tiles: &[Image], output_dir: &str) { + let output_path = Path::new(output_dir); + std::fs::create_dir_all(output_path).expect("Failed to create output directory"); + + for (i, tile) in tiles.iter().enumerate() { + let file_path = output_path.join(format!("tile_{:03}.png", i)); + if let Some(data) = tile.encode_to_data(skia_safe::EncodedImageFormat::PNG) { + std::fs::write(file_path, data.as_bytes()).expect("Failed to write tile"); + } + } +} + +fn main() { + // Create a scene with 1000x1000 dimensions + let scene = TiledScene::new(1000.0, 1000.0); + + // Render the scene into 200x200 tiles + let tiles = scene.render_to_tiles(200); + + // Save the tiles to the output directory + save_tiles(&tiles, "tiles_output"); + + println!("Generated {} tiles in tiles_output directory", tiles.len()); +} diff --git a/crates/grida-canvas/examples/webfonts.rs b/crates/grida-canvas/examples/webfonts.rs new file mode 100644 index 0000000000..a25e0883ad --- /dev/null +++ b/crates/grida-canvas/examples/webfonts.rs @@ -0,0 +1,220 @@ +use cg::font_loader::FontLoader; +use cg::node::factory::NodeFactory; +use cg::node::repository::NodeRepository; +use cg::node::schema::*; +use cg::webfont_helper::{find_font_files_by_family, load_webfonts_metadata}; +use cg::window; +use futures::future::join_all; +use math2::transform::AffineTransform; + +const PARAGRAPH: &str = r#" +This demo showcases how multiple static TTF font files—each representing a different weight or style—can be loaded under the same font family name. It verifies that our system can correctly resolve and apply the appropriate font file when rendering text spans with varying font weights and styles. Each text line uses the same font family (“Albert Sans”) but specifies a different combination of weight and italic flag. If the system behaves correctly, the rendered output should match the intended visual style for each variant, demonstrating accurate font resolution and fallback handling within the shared family context. +"#; + +async fn demo_webfonts() -> Scene { + let nf = NodeFactory::new(); + + // Create a heading with Playfair Display + let mut heading_node = nf.create_text_span_node(); + heading_node.base.name = "Heading".to_string(); + heading_node.transform = AffineTransform::new(50.0, 50.0, 0.0); + heading_node.size = Size { + width: 800.0, + height: 100.0, + }; + heading_node.text = "Web fonts demo".to_string(); + heading_node.text_style = TextStyle { + text_decoration: TextDecoration::None, + font_family: "Playfair Display".to_string(), + font_size: 64.0, + font_weight: FontWeight::new(700), // Bold + letter_spacing: None, + italic: false, + line_height: None, + text_transform: TextTransform::None, + }; + heading_node.text_align = TextAlign::Left; + heading_node.text_align_vertical = TextAlignVertical::Top; + + // Create a description paragraph with Playfair Display + let mut description_node = nf.create_text_span_node(); + description_node.base.name = "Description".to_string(); + description_node.transform = AffineTransform::new(50.0, 120.0, 0.0); + description_node.size = Size { + width: 800.0, + height: 120.0, + }; + description_node.text = PARAGRAPH.to_string(); + description_node.text_style = TextStyle { + text_decoration: TextDecoration::None, + font_family: "Playfair Display".to_string(), + font_size: 14.0, + font_weight: FontWeight::new(400), // Regular + letter_spacing: None, + italic: false, + line_height: Some(1.5), // 1.5 line height for better readability + text_transform: TextTransform::None, + }; + description_node.text_align = TextAlign::Left; + description_node.text_align_vertical = TextAlignVertical::Top; + + // Create text nodes for Albert Sans variants + let mut albert_text_nodes = Vec::new(); + let variants = [ + ("Regular", 400, false), + ("Thin", 100, false), + ("ExtraLight", 200, false), + ("Light", 300, false), + ("Medium", 500, false), + ("SemiBold", 600, false), + ("Bold", 700, false), + ("ExtraBold", 800, false), + ("Black", 900, false), + ("ThinItalic", 100, true), + ("ExtraLightItalic", 200, true), + ("LightItalic", 300, true), + ("MediumItalic", 500, true), + ("SemiBoldItalic", 600, true), + ("BoldItalic", 700, true), + ("ExtraBoldItalic", 800, true), + ("BlackItalic", 900, true), + ]; + + for (i, (variant, weight, is_italic)) in variants.iter().enumerate() { + let mut text_node = nf.create_text_span_node(); + text_node.base.name = format!("Albert Sans {}", variant); + text_node.transform = AffineTransform::new(50.0, 280.0 + (i as f32 * 40.0), 0.0); + text_node.size = Size { + width: 800.0, + height: 40.0, + }; + text_node.text = format!("AlbertSans {}", variant); + text_node.text_style = TextStyle { + text_decoration: TextDecoration::None, + font_family: "Albert Sans".to_string(), + font_size: 24.0, + font_weight: FontWeight::new(*weight), + letter_spacing: None, + italic: *is_italic, + line_height: None, + text_transform: TextTransform::None, + }; + text_node.text_align = TextAlign::Left; + text_node.text_align_vertical = TextAlignVertical::Top; + albert_text_nodes.push(text_node); + } + + // Create a root container node + let mut root_container_node = nf.create_container_node(); + root_container_node.base.name = "Root Container".to_string(); + root_container_node.size = Size { + width: 1080.0, + height: 1080.0, + }; + + // Create a node repository and add all nodes + let mut repository = NodeRepository::new(); + + // Collect all the IDs + let heading_id = heading_node.base.id.clone(); + let description_id = description_node.base.id.clone(); + let albert_text_ids: Vec<_> = albert_text_nodes + .iter() + .map(|n| n.base.id.clone()) + .collect(); + + // Add all nodes to the repository + repository.insert(Node::TextSpan(heading_node)); + repository.insert(Node::TextSpan(description_node)); + for text_node in albert_text_nodes { + repository.insert(Node::TextSpan(text_node)); + } + + // Set up the root container with all IDs + let mut children = vec![heading_id, description_id]; + children.extend(albert_text_ids); + root_container_node.children = children; + let root_container_id = root_container_node.base.id.clone(); + repository.insert(Node::Container(root_container_node)); + + Scene { + id: "scene".to_string(), + name: "Webfonts Demo".to_string(), + transform: AffineTransform::identity(), + children: vec![root_container_id], + nodes: repository, + background_color: Some(Color(250, 250, 250, 255)), + } +} + +#[tokio::main] +async fn main() { + let scene = demo_webfonts().await; + + // Load webfonts metadata and find matching font files + let webfonts_metadata = load_webfonts_metadata() + .await + .expect("Failed to load webfonts metadata"); + + // Get the fonts we need for this demo + let required_fonts = vec!["Playfair Display", "Albert Sans"] + .into_iter() + .map(|s| s.to_string()) + .collect::>(); + let font_files = find_font_files_by_family(&webfonts_metadata, &required_fonts); + + println!("\nFound {} matching font files:", font_files.len()); + for font_file in &font_files { + println!("Font: {} ({})", font_file.family, font_file.postscript_name); + println!(" Style: {}", font_file.style); + println!(" URL: {}", font_file.url); + println!(); + } + + // Clone the scene before passing it to run_demo_window_with + let scene_for_window = scene.clone(); + let font_files_clone = font_files.clone(); + + window::run_demo_window_with( + scene_for_window, + move |_renderer, _img_tx, font_tx, proxy| { + println!("📝 Initializing font loader..."); + // No need to create a FontLoader here + + // Load all fonts in the scene - non-blocking + println!("🔄 Starting to load scene fonts in background..."); + let font_files = font_files_clone.clone(); + let font_tx = font_tx.clone(); + let proxy = proxy.clone(); + tokio::spawn(async move { + let font_loading_futures: Vec<_> = font_files + .into_iter() + .map(|font_file| { + let font_tx = font_tx.clone(); + let proxy = proxy.clone(); + async move { + let family = font_file.family; + let style = font_file.style; + let url = font_file.url; + let postscript_name = font_file.postscript_name; + println!("Loading font: {} ({})", family, postscript_name); + let mut font_loader = FontLoader::new_lifecycle(font_tx, proxy); + font_loader + .load_font_with_style(&family, Some(&style), &url) + .await; + println!("✅ Font loaded: {} ({})", family, postscript_name); + } + }) + .collect(); + join_all(font_loading_futures).await; + println!("✅ Scene fonts loading completed in background"); + println!("\n🔍 Font Repository Information:"); + println!("================================"); + println!("All fonts have been loaded and sent to the renderer."); + println!("Check the console output above for registration messages."); + println!("The renderer's font repository now contains the loaded fonts."); + }); + }, + ) + .await; +} diff --git a/crates/grida-canvas/examples/winit_raf.rs b/crates/grida-canvas/examples/winit_raf.rs new file mode 100644 index 0000000000..4e16604e27 --- /dev/null +++ b/crates/grida-canvas/examples/winit_raf.rs @@ -0,0 +1,89 @@ +use cg::window::scheduler; +use rand::Rng; +use std::time::{Duration, Instant}; +use winit::{ + application::ApplicationHandler, + dpi::LogicalSize, + event_loop::EventLoop, + window::{Window, WindowAttributes}, +}; + +// Main application struct holding the window and rendering loop state +struct App { + window: Window, + frame_count: u32, + start_time: Instant, + scheduler: scheduler::FrameScheduler, +} + +impl App { + /// Simulates rendering work by sleeping for a few milliseconds + fn render(&self) { + let mut rng = rand::thread_rng(); + let render_time = rng.gen_range(1..=5); + std::thread::sleep(Duration::from_millis(render_time)); + } +} + +impl ApplicationHandler for App { + fn resumed(&mut self, _event_loop: &winit::event_loop::ActiveEventLoop) {} + + fn window_event( + &mut self, + event_loop: &winit::event_loop::ActiveEventLoop, + _window_id: winit::window::WindowId, + event: winit::event::WindowEvent, + ) { + match event { + // Exit gracefully when the window is closed + winit::event::WindowEvent::CloseRequested => { + event_loop.exit(); + } + // Called once per frame when redraw is requested + winit::event::WindowEvent::RedrawRequested => { + self.render(); // Simulate some frame rendering work + + self.scheduler.sleep_to_maintain_fps(); // Apply pacing (no-op on wasm) + + self.frame_count += 1; + + // Log FPS every second + let elapsed = self.start_time.elapsed(); + if elapsed >= Duration::from_secs(1) { + println!("Frames in last second: {}", self.frame_count); + self.frame_count = 0; + self.start_time = Instant::now(); + } + + self.window.request_redraw(); // Schedule next frame + } + _ => {} + } + } +} + +fn main() { + // Set up the winit event loop and window + let el = EventLoop::new().expect("Failed to create event loop"); + + let window_attributes = WindowAttributes::default() + .with_title("Winit RAF Demo") + .with_inner_size(LogicalSize::new(800, 600)); + + let window = el + .create_window(window_attributes) + .expect("Failed to create window"); + + let now = Instant::now(); + + // Initialize application with both a target and max FPS + let mut app = App { + window, + frame_count: 0, + start_time: now, + scheduler: scheduler::FrameScheduler::new(120).with_max_fps(144), + }; + + // Start the app's event loop + el.run_app(&mut app).unwrap(); +} diff --git a/crates/grida-canvas/package.json b/crates/grida-canvas/package.json new file mode 100644 index 0000000000..02b2b3f242 --- /dev/null +++ b/crates/grida-canvas/package.json @@ -0,0 +1,9 @@ +{ + "name": "@crates/grida-canvas", + "description": "turbo ci rust wrapper", + "private": true, + "scripts": { + "build": "cargo build --release", + "test": "cargo test" + } +} diff --git a/crates/grida-canvas/resources/.gitignore b/crates/grida-canvas/resources/.gitignore new file mode 100644 index 0000000000..c2c027fec1 --- /dev/null +++ b/crates/grida-canvas/resources/.gitignore @@ -0,0 +1 @@ +local \ No newline at end of file diff --git a/crates/grida-canvas/resources/4k.jpg b/crates/grida-canvas/resources/4k.jpg new file mode 100644 index 0000000000..9edcb0a229 Binary files /dev/null and b/crates/grida-canvas/resources/4k.jpg differ diff --git a/crates/grida-canvas/resources/8k.jpg b/crates/grida-canvas/resources/8k.jpg new file mode 100644 index 0000000000..c484e95593 Binary files /dev/null and b/crates/grida-canvas/resources/8k.jpg differ diff --git a/crates/grida-canvas/resources/Bungee-Regular.ttf b/crates/grida-canvas/resources/Bungee-Regular.ttf new file mode 100644 index 0000000000..e1721aecb5 Binary files /dev/null and b/crates/grida-canvas/resources/Bungee-Regular.ttf differ diff --git a/crates/grida-canvas/resources/Caveat-VariableFont_wght.ttf b/crates/grida-canvas/resources/Caveat-VariableFont_wght.ttf new file mode 100644 index 0000000000..5adc65821c Binary files /dev/null and b/crates/grida-canvas/resources/Caveat-VariableFont_wght.ttf differ diff --git a/crates/grida-canvas/resources/Fruktur-Regular.ttf b/crates/grida-canvas/resources/Fruktur-Regular.ttf new file mode 100644 index 0000000000..7dbaae0c2a Binary files /dev/null and b/crates/grida-canvas/resources/Fruktur-Regular.ttf differ diff --git a/crates/grida-canvas/resources/VT323-Regular.ttf b/crates/grida-canvas/resources/VT323-Regular.ttf new file mode 100644 index 0000000000..6aec59930e Binary files /dev/null and b/crates/grida-canvas/resources/VT323-Regular.ttf differ diff --git a/crates/grida-canvas/src/cache/README.md b/crates/grida-canvas/src/cache/README.md new file mode 100644 index 0000000000..481d07eeb9 --- /dev/null +++ b/crates/grida-canvas/src/cache/README.md @@ -0,0 +1,75 @@ +# Cache + +A high-performance runtime store for managing per-node spatial data in a scene graph. + +## Geometry Cache + +## Purpose + +The Geometry Cache exists to store and maintain computed geometric properties of nodes in a scene, independent from the user-authored structure. It enables fast rendering, culling, and future hit-testing by treating transform and bounds as first-class cached data. + +### Responsibilities + +- Maintain **relative (local)** and **absolute (world)** transforms. +- Cache **local** and **world-space bounding boxes**. +- Propagate **dirty flags** on transform or geometry change. +- Support optional future features like path-based hit-testing. + +### Philosophy + +1. **Authoring is relative. Rendering is absolute.** + + - `local_transform` is the source of truth. + - `world_transform` is cached and derived. + +2. **Flat is fast.** + + - The scene is a tree; the cache is a flat map. + +3. **Dirty flags are sacred.** + + - Only update what has changed. + +4. **Query is constant-time.** + + - `get_world_bounds(node_id)` is always O(1) after update. + +5. **No recursion at runtime.** + + - Updates are done top-down, in pre-sorted order or with explicit parent tracking. + +### Core Data Structure + +```rust +struct GeometryEntry { + local_transform: Transform2D, + world_transform: Transform2D, + local_bounds: Rect, + world_bounds: Rect, + parent: Option, + dirty_transform: bool, + dirty_bounds: bool, + // future: path: Option, +} + +struct GeometryCache { + entries: HashMap, +} +``` + +### Operations + +- `mark_dirty(node_id)` — recursively mark transform + bounds dirty. +- `update(node_id)` — resolve world transform + world bounds. +- `get_world_transform(node_id)` — O(1) after update. +- `get_world_bounds(node_id)` — O(1) after update. + +### Why Not a BVH? + +This cache stores per-node spatial data. A BVH is a secondary structure built _from_ this cache — optimized for queries like ray intersections, pointer picking, and region invalidation. + +--- + +**GeometryCache is not just a helper — it is the spatial truth of the engine.** + +It is designed to be deterministic, cache-efficient, and ready for future integration with spatial indexing, GPU transform buffers, or collaborative scene systems. diff --git a/crates/grida-canvas/src/cache/geometry.rs b/crates/grida-canvas/src/cache/geometry.rs new file mode 100644 index 0000000000..b27998986e --- /dev/null +++ b/crates/grida-canvas/src/cache/geometry.rs @@ -0,0 +1,505 @@ +use crate::node::repository::NodeRepository; +use crate::node::schema::{FilterEffect, IntrinsicSizeNode, Node, NodeId, Scene, StrokeAlign}; +use math2::rect; +use math2::rect::Rectangle; +use math2::transform::AffineTransform; +use std::collections::HashMap; + +/// Geometry data used for layout, culling, and rendering. +/// +/// `local_bounds` and `world_bounds` represent the tight geometry bounds of the shape. +/// `render_bounds` includes visual overflow from effects such as blur, stroke, or shadows, +/// and is used for visibility culling and picture recording regions. +/// +/// All bounds are updated during geometry cache construction and reused throughout the pipeline. +#[derive(Debug, Clone)] +pub struct GeometryEntry { + /// relative transform + pub transform: AffineTransform, + /// absolute (world) transform + pub absolute_transform: AffineTransform, + /// relative AABB (after the transform is applied) + pub bounding_box: Rectangle, + /// absolute (world) AABB (after the transform is applied) + pub absolute_bounding_box: Rectangle, + /// Expanded bounds that include visual effects like blur, shadow, stroke, etc. + /// Used for render-time culling and picture recording. + pub absolute_render_bounds: Rectangle, + pub parent: Option, + pub dirty_transform: bool, + pub dirty_bounds: bool, +} + +#[derive(Debug, Clone)] +pub struct GeometryCache { + entries: HashMap, +} + +impl GeometryCache { + pub fn new() -> Self { + Self { + entries: HashMap::new(), + } + } + + pub fn from_scene(scene: &Scene) -> Self { + let mut cache = Self::new(); + let root_world = scene.transform; + for child in &scene.children { + Self::build_recursive(child, &scene.nodes, &root_world, None, &mut cache); + } + cache + } + + fn build_recursive( + id: &NodeId, + repo: &NodeRepository, + parent_world: &AffineTransform, + parent_id: Option, + cache: &mut GeometryCache, + ) -> Rectangle { + let node = repo.get(id).expect("node not found"); + + match node { + Node::Group(n) => { + let world_transform = parent_world.compose(&n.transform); + let mut union_bounds: Option = None; + let mut union_render_bounds: Option = None; + for child_id in &n.children { + let child_bounds = Self::build_recursive( + child_id, + repo, + &world_transform, + Some(id.clone()), + cache, + ); + union_bounds = match union_bounds { + Some(b) => Some(rect::union(&[b, child_bounds])), + None => Some(child_bounds), + }; + if let Some(rb) = cache.get_render_bounds(child_id) { + union_render_bounds = match union_render_bounds { + Some(b) => Some(rect::union(&[b, rb])), + None => Some(rb), + }; + } + } + + let world_bounds = union_bounds.unwrap_or_else(|| Rectangle { + x: 0.0, + y: 0.0, + width: 0.0, + height: 0.0, + }); + + let local_bounds = if let Some(inv) = world_transform.inverse() { + transform_rect(&world_bounds, &inv) + } else { + Rectangle { + x: 0.0, + y: 0.0, + width: 0.0, + height: 0.0, + } + }; + + let render_bounds = union_render_bounds.unwrap_or(world_bounds); + + let entry = GeometryEntry { + transform: n.transform, + absolute_transform: world_transform, + bounding_box: local_bounds, + absolute_bounding_box: world_bounds, + absolute_render_bounds: render_bounds, + parent: parent_id.clone(), + dirty_transform: false, + dirty_bounds: false, + }; + + cache.entries.insert(id.clone(), entry.clone()); + entry.absolute_bounding_box + } + Node::BooleanOperation(n) => { + let world_transform = parent_world.compose(&n.transform); + let mut union_bounds: Option = None; + for child_id in &n.children { + let child_bounds = Self::build_recursive( + child_id, + repo, + &world_transform, + Some(id.clone()), + cache, + ); + union_bounds = match union_bounds { + Some(b) => Some(rect::union(&[b, child_bounds])), + None => Some(child_bounds), + }; + } + + let world_bounds = union_bounds.unwrap_or_else(|| Rectangle { + x: 0.0, + y: 0.0, + width: 0.0, + height: 0.0, + }); + + let local_bounds = if let Some(inv) = world_transform.inverse() { + transform_rect(&world_bounds, &inv) + } else { + Rectangle { + x: 0.0, + y: 0.0, + width: 0.0, + height: 0.0, + } + }; + + let render_bounds = compute_render_bounds_from_style( + world_bounds, + if n.stroke.is_some() { + n.stroke_width + } else { + 0.0 + }, + n.stroke_align, + n.effect.as_ref(), + ); + + let entry = GeometryEntry { + transform: n.transform, + absolute_transform: world_transform, + bounding_box: local_bounds, + absolute_bounding_box: world_bounds, + absolute_render_bounds: render_bounds, + parent: parent_id.clone(), + dirty_transform: false, + dirty_bounds: false, + }; + + cache.entries.insert(id.clone(), entry.clone()); + entry.absolute_bounding_box + } + Node::Container(n) => { + let local_transform = n.transform; + let world_transform = parent_world.compose(&local_transform); + let local_bounds = n.rect(); + let world_bounds = transform_rect(&local_bounds, &world_transform); + let mut union_world_bounds = world_bounds; + let render_bounds = compute_render_bounds_from_style( + world_bounds, + if n.stroke.is_some() { + n.stroke_width + } else { + 0.0 + }, + n.stroke_align, + n.effect.as_ref(), + ); + + for child_id in &n.children { + let child_bounds = Self::build_recursive( + child_id, + repo, + &world_transform, + Some(id.clone()), + cache, + ); + union_world_bounds = rect::union(&[union_world_bounds, child_bounds]); + } + + let entry = GeometryEntry { + transform: local_transform, + absolute_transform: world_transform, + bounding_box: local_bounds, + absolute_bounding_box: world_bounds, + absolute_render_bounds: render_bounds, + parent: parent_id.clone(), + dirty_transform: false, + dirty_bounds: false, + }; + cache.entries.insert(id.clone(), entry.clone()); + + union_world_bounds + } + _ => { + let intrinsic_node = Box::new(match node { + Node::Path(n) => IntrinsicSizeNode::Path(n.clone()), + Node::Rectangle(n) => IntrinsicSizeNode::Rectangle(n.clone()), + Node::Ellipse(n) => IntrinsicSizeNode::Ellipse(n.clone()), + Node::Polygon(n) => IntrinsicSizeNode::Polygon(n.clone()), + Node::RegularPolygon(n) => IntrinsicSizeNode::RegularPolygon(n.clone()), + Node::RegularStarPolygon(n) => IntrinsicSizeNode::RegularStarPolygon(n.clone()), + Node::Line(n) => IntrinsicSizeNode::Line(n.clone()), + Node::TextSpan(n) => IntrinsicSizeNode::TextSpan(n.clone()), + Node::Image(n) => IntrinsicSizeNode::Image(n.clone()), + Node::Container(n) => IntrinsicSizeNode::Container(n.clone()), + Node::Error(n) => IntrinsicSizeNode::Error(n.clone()), + Node::Group(_) | Node::BooleanOperation(_) => panic!("Unsupported node type"), + }); + let intrinsic = intrinsic_node.as_ref(); + + let (local_transform, local_bounds) = node_geometry(intrinsic); + let world_transform = parent_world.compose(&local_transform); + let world_bounds = transform_rect(&local_bounds, &world_transform); + let render_bounds = compute_render_bounds(node, world_bounds); + + let entry = GeometryEntry { + transform: local_transform, + absolute_transform: world_transform, + bounding_box: local_bounds, + absolute_bounding_box: world_bounds, + absolute_render_bounds: render_bounds, + parent: parent_id.clone(), + dirty_transform: false, + dirty_bounds: false, + }; + + cache.entries.insert(id.clone(), entry.clone()); + entry.absolute_bounding_box + } + } + } + + pub fn get_world_transform(&self, id: &NodeId) -> Option { + self.entries.get(id).map(|e| e.absolute_transform) + } + + pub fn get_world_bounds(&self, id: &NodeId) -> Option { + self.entries.get(id).map(|e| e.absolute_bounding_box) + } + + /// Return expanded render bounds for a node if available. + pub fn get_render_bounds(&self, id: &NodeId) -> Option { + self.entries.get(id).map(|e| e.absolute_render_bounds) + } + + /// Return the parent NodeId for a given node if available. + pub fn get_parent(&self, id: &NodeId) -> Option { + self.entries.get(id).and_then(|e| e.parent.clone()) + } + + pub fn len(&self) -> usize { + self.entries.len() + } + + pub fn has(&self, id: &NodeId) -> bool { + self.entries.contains_key(id) + } + + /// filter by node id and its entry data + pub fn filter(&self, filter: impl Fn(&NodeId, &GeometryEntry) -> bool) -> Self { + Self { + entries: self + .entries + .iter() + .filter(|(id, entry)| filter(id, entry)) + .map(|(id, entry)| (id.clone(), entry.clone())) + .collect(), + } + } +} + +fn node_geometry(node: &IntrinsicSizeNode) -> (AffineTransform, Rectangle) { + match node { + IntrinsicSizeNode::Error(n) => (n.transform, n.rect()), + IntrinsicSizeNode::Container(n) => (n.transform, n.rect()), + IntrinsicSizeNode::Rectangle(n) => (n.transform, n.rect()), + IntrinsicSizeNode::Ellipse(n) => (n.transform, n.rect()), + IntrinsicSizeNode::Polygon(n) => (n.transform, polygon_bounds(&n.points)), + IntrinsicSizeNode::RegularPolygon(n) => (n.transform, n.rect()), + IntrinsicSizeNode::RegularStarPolygon(n) => (n.transform, n.rect()), + IntrinsicSizeNode::Line(n) => ( + n.transform, + Rectangle { + x: 0.0, + y: 0.0, + width: n.size.width, + height: 0.0, + }, + ), + IntrinsicSizeNode::TextSpan(n) => ( + n.transform, + Rectangle { + x: 0.0, + y: 0.0, + width: n.size.width, + height: n.size.height, + }, + ), + IntrinsicSizeNode::Path(n) => (n.transform, path_bounds(&n.data)), + IntrinsicSizeNode::Image(n) => (n.transform, n.rect()), + } +} + +fn transform_rect(rect: &Rectangle, t: &AffineTransform) -> Rectangle { + rect::transform(*rect, t) +} + +fn polygon_bounds(points: &[crate::node::schema::Point]) -> Rectangle { + let mut min_x = f32::INFINITY; + let mut min_y = f32::INFINITY; + let mut max_x = f32::NEG_INFINITY; + let mut max_y = f32::NEG_INFINITY; + for p in points { + min_x = min_x.min(p.x); + min_y = min_y.min(p.y); + max_x = max_x.max(p.x); + max_y = max_y.max(p.y); + } + if points.is_empty() { + Rectangle { + x: 0.0, + y: 0.0, + width: 0.0, + height: 0.0, + } + } else { + Rectangle { + x: min_x, + y: min_y, + width: max_x - min_x, + height: max_y - min_y, + } + } +} + +fn path_bounds(data: &str) -> Rectangle { + if let Some(path) = skia_safe::path::Path::from_svg(data) { + let b = path.compute_tight_bounds(); + Rectangle { + x: b.left(), + y: b.top(), + width: b.width(), + height: b.height(), + } + } else { + Rectangle { + x: 0.0, + y: 0.0, + width: 0.0, + height: 0.0, + } + } +} + +fn inflate_rect(rect: Rectangle, delta: f32) -> Rectangle { + if delta <= 0.0 { + return rect; + } + Rectangle { + x: rect.x - delta, + y: rect.y - delta, + width: rect.width + 2.0 * delta, + height: rect.height + 2.0 * delta, + } +} + +fn stroke_outset(align: StrokeAlign, width: f32) -> f32 { + match align { + StrokeAlign::Inside => 0.0, + StrokeAlign::Center => width / 2.0, + StrokeAlign::Outside => width, + } +} + +fn compute_render_bounds_from_style( + world_bounds: Rectangle, + stroke_width: f32, + stroke_align: StrokeAlign, + effect: Option<&FilterEffect>, +) -> Rectangle { + let mut bounds = inflate_rect(world_bounds, stroke_outset(stroke_align, stroke_width)); + + if let Some(effect) = effect { + match effect { + FilterEffect::GaussianBlur(blur) => { + bounds = inflate_rect(bounds, blur.radius); + } + FilterEffect::BackdropBlur(blur) => { + bounds = inflate_rect(bounds, blur.radius); + } + FilterEffect::DropShadow(shadow) => { + let shadow_rect = inflate_rect( + Rectangle { + x: world_bounds.x + shadow.dx, + y: world_bounds.y + shadow.dy, + width: world_bounds.width, + height: world_bounds.height, + }, + shadow.blur, + ); + bounds = rect::union(&[bounds, shadow_rect]); + } + } + } + + bounds +} + +fn compute_render_bounds(node: &Node, world_bounds: Rectangle) -> Rectangle { + match node { + Node::Rectangle(n) => compute_render_bounds_from_style( + world_bounds, + n.stroke_width, + n.stroke_align, + n.effect.as_ref(), + ), + Node::Ellipse(n) => compute_render_bounds_from_style( + world_bounds, + n.stroke_width, + n.stroke_align, + n.effect.as_ref(), + ), + Node::Polygon(n) => compute_render_bounds_from_style( + world_bounds, + n.stroke_width, + n.stroke_align, + n.effect.as_ref(), + ), + Node::RegularPolygon(n) => compute_render_bounds_from_style( + world_bounds, + n.stroke_width, + n.stroke_align, + n.effect.as_ref(), + ), + Node::RegularStarPolygon(n) => compute_render_bounds_from_style( + world_bounds, + n.stroke_width, + n.stroke_align, + n.effect.as_ref(), + ), + Node::Path(n) => compute_render_bounds_from_style( + world_bounds, + n.stroke_width, + n.stroke_align, + n.effect.as_ref(), + ), + Node::Image(n) => compute_render_bounds_from_style( + world_bounds, + n.stroke_width, + n.stroke_align, + n.effect.as_ref(), + ), + Node::Line(n) => { + compute_render_bounds_from_style(world_bounds, n.stroke_width, n.stroke_align, None) + } + Node::TextSpan(n) => compute_render_bounds_from_style( + world_bounds, + n.stroke_width.unwrap_or(0.0), + n.stroke_align, + None, + ), + Node::Container(n) => compute_render_bounds_from_style( + world_bounds, + if n.stroke.is_some() { + n.stroke_width + } else { + 0.0 + }, + n.stroke_align, + n.effect.as_ref(), + ), + Node::Error(_) => world_bounds, + Node::Group(_) | Node::BooleanOperation(_) => world_bounds, + } +} diff --git a/crates/grida-canvas/src/cache/mod.rs b/crates/grida-canvas/src/cache/mod.rs new file mode 100644 index 0000000000..b89ab90649 --- /dev/null +++ b/crates/grida-canvas/src/cache/mod.rs @@ -0,0 +1,4 @@ +pub mod geometry; +pub mod picture; +pub mod scene; +pub mod tile; diff --git a/crates/grida-canvas/src/cache/picture.rs b/crates/grida-canvas/src/cache/picture.rs new file mode 100644 index 0000000000..cc2b293f6a --- /dev/null +++ b/crates/grida-canvas/src/cache/picture.rs @@ -0,0 +1,63 @@ +use crate::node::schema::NodeId; +use skia_safe::Picture; +use std::collections::HashMap; + +/// Configuration for how the scene should be cached. +/// +/// Currently only the `depth` parameter is used: +/// - `None` caches the entire scene as a single picture. +/// - `Some(depth)` caches up to `depth` levels of nodes separately. +#[derive(Debug, Clone)] +pub struct PictureCacheStrategy { + pub depth: Option, +} + +impl Default for PictureCacheStrategy { + fn default() -> Self { + Self { depth: None } + } +} + +#[derive(Debug, Clone)] +pub struct PictureCache { + strategy: PictureCacheStrategy, + node_pictures: HashMap, +} + +impl PictureCache { + pub fn new() -> Self { + Self { + strategy: PictureCacheStrategy::default(), + node_pictures: HashMap::new(), + } + } + + pub fn strategy(&self) -> &PictureCacheStrategy { + &self.strategy + } + + pub fn set_strategy(&mut self, strategy: PictureCacheStrategy) { + self.strategy = strategy; + self.invalidate(); + } + + pub fn get_node_picture(&self, id: &NodeId) -> Option<&Picture> { + self.node_pictures.get(id) + } + + pub fn set_node_picture(&mut self, id: NodeId, picture: Picture) { + self.node_pictures.insert(id, picture); + } + + pub fn len(&self) -> usize { + self.node_pictures.len() + } + + pub fn depth(&self) -> Option { + self.strategy.depth + } + + pub fn invalidate(&mut self) { + self.node_pictures.clear(); + } +} diff --git a/crates/grida-canvas/src/cache/scene.rs b/crates/grida-canvas/src/cache/scene.rs new file mode 100644 index 0000000000..71ca70beac --- /dev/null +++ b/crates/grida-canvas/src/cache/scene.rs @@ -0,0 +1,166 @@ +use crate::node::schema::{NodeId, Scene}; +use crate::runtime::camera::Camera2D; +use crate::{ + cache::{ + geometry::GeometryCache, + picture::{PictureCache, PictureCacheStrategy}, + tile::ImageTileCache, + }, + painter::layer::{Layer, LayerList}, +}; +use math2::{rect::Rectangle, vector2::Vector2}; +use rstar::{AABB, RTree, RTreeObject}; +use skia_safe::{Picture, Surface}; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct IndexedLayer { + pub index: usize, + pub bounds: AABB<[f32; 2]>, +} + +impl RTreeObject for IndexedLayer { + type Envelope = AABB<[f32; 2]>; + + fn envelope(&self) -> Self::Envelope { + self.bounds + } +} + +/// A unified cache storing geometry information and recorded pictures for a scene. +#[derive(Debug, Clone)] +pub struct SceneCache { + pub layers: LayerList, + pub geometry: GeometryCache, + pub picture: PictureCache, + pub tile: ImageTileCache, + pub layer_index: RTree, +} + +impl SceneCache { + /// Create a new empty cache with the given picture cache strategy. + pub fn new() -> Self { + Self { + layers: LayerList::default(), + geometry: GeometryCache::new(), + picture: PictureCache::new(), + tile: ImageTileCache::new(), + layer_index: RTree::new(), + } + } + + /// Rebuild the geometry cache from the provided scene. + pub fn update_geometry(&mut self, scene: &Scene) { + self.geometry = GeometryCache::from_scene(scene); + } + + pub fn update_layers(&mut self, scene: &Scene) { + self.layers = LayerList::from_scene(scene, &self.geometry); + self.layers.layers.sort_by_key(|l| l.z_index()); + self.layer_index = RTree::new(); + for (i, layer) in self.layers.layers.iter().enumerate() { + if let Some(rb) = self.geometry.get_render_bounds(&layer.id()) { + let bounds = AABB::from_corners([rb.x, rb.y], [rb.x + rb.width, rb.y + rb.height]); + self.layer_index.insert(IndexedLayer { index: i, bounds }); + } + } + } + + /// Access the geometry cache. + pub fn geometry(&self) -> &GeometryCache { + &self.geometry + } + + /// Mutable access to the geometry cache. + pub fn geometry_mut(&mut self) -> &mut GeometryCache { + &mut self.geometry + } + + /// Access the picture cache. + pub fn picture(&self) -> &PictureCache { + &self.picture + } + + /// Mutable access to the picture cache. + pub fn picture_mut(&mut self) -> &mut PictureCache { + &mut self.picture + } + + /// Retrieve the current picture cache strategy. + pub fn picture_strategy(&self) -> &PictureCacheStrategy { + self.picture.strategy() + } + + /// Invalidate all cached data. + pub fn invalidate(&mut self) { + self.picture.invalidate(); + } + + /// Return a picture for a specific node if cached. + pub fn get_node_picture(&self, id: &NodeId) -> Option<&Picture> { + self.picture.get_node_picture(id) + } + + /// Store a picture for a node. + pub fn set_node_picture(&mut self, id: NodeId, picture: Picture) { + self.picture.set_node_picture(id, picture); + } + + /// Query painter layer indices whose bounds intersect with the given rectangle. + /// This includes layers that are: + /// - Fully contained within the rectangle + /// - Partially overlapping with the rectangle + /// - Touching the rectangle's edges + pub fn intersects(&self, rect: Rectangle) -> Vec { + let env = AABB::from_corners( + [rect.x, rect.y], + [rect.x + rect.width, rect.y + rect.height], + ); + self.layer_index + .locate_in_envelope_intersecting(&env) + .map(|il| il.index) + .collect() + } + + /// Query painter layer indices whose bounds are fully contained within the given rectangle. + /// This only includes layers that are completely inside the rectangle, not touching its edges. + pub fn contains(&self, rect: &Rectangle) -> Vec { + // Get layers that are fully contained + let env = AABB::from_corners( + [rect.x, rect.y], + [rect.x + rect.width, rect.y + rect.height], + ); + self.layer_index + .locate_in_envelope(&env) + .map(|il| il.index) + .collect() + } + + /// Query painter layer indices whose bounds contain the given point. + pub fn intersects_point(&self, point: Vector2) -> Vec { + let env = AABB::from_point([point[0], point[1]]); + self.layer_index + .locate_in_envelope_intersecting(&env) + .map(|il| il.index) + .collect() + } + + /// Update raster tile cache using the given camera and surface. + pub fn update_tiles( + &mut self, + camera: &Camera2D, + surface: &mut Surface, + width: f32, + height: f32, + ) { + let index = &self.layer_index; + let intersects = |rect: Rectangle| { + let env = AABB::from_corners( + [rect.x, rect.y], + [rect.x + rect.width, rect.y + rect.height], + ); + index.locate_in_envelope_intersecting(&env).next().is_some() + }; + self.tile + .update_tiles(camera, width, height, surface, intersects); + } +} diff --git a/crates/grida-canvas/src/cache/tile.rs b/crates/grida-canvas/src/cache/tile.rs new file mode 100644 index 0000000000..543970578a --- /dev/null +++ b/crates/grida-canvas/src/cache/tile.rs @@ -0,0 +1,438 @@ +use crate::runtime::camera::Camera2D; +use math2::rect::{self, Rectangle}; +use skia_safe::{IRect, Image, Surface}; +use std::collections::HashMap; +use std::rc::Rc; +use std::time::{Duration, Instant}; + +/// Debounce duration after zooming before capturing tiles +const CACHE_DEBOUNCE_BY_ZOOM: Duration = Duration::from_millis(500); + +/// (x, y, width, height) in canvas space +#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)] +pub struct TileRectKey(pub i32, pub i32, pub u32, pub u32); + +impl TileRectKey { + pub fn to_rect(&self) -> Rectangle { + Rectangle { + x: self.0 as f32, + y: self.1 as f32, + width: self.2 as f32, + height: self.3 as f32, + } + } +} + +#[derive(Debug, Clone)] +pub struct TileAtZoom { + pub image: Rc, + pub zoom: f32, +} + +/// Information about a tile including whether it should be blurred +#[derive(Debug, Clone)] +pub struct RegionTileInfo { + pub key: TileRectKey, + /// When true, the tile should be blurred as it was captured at a lower zoom level + /// (lower resolution) than the current view + pub blur: bool, + /// The blur radius to use for this tile (adaptive by zoom difference) + pub blur_radius: f32, + /// The zoom level at which this tile was snapshotted + pub zoom: f32, +} + +/// A collection of tiles for a specific region with blur information and sorting. +/// This encapsulates the logic for retrieving tiles from the cache and calculating +/// blur parameters based on zoom differences. +#[derive(Debug, Clone)] +pub struct RegionTiles { + pub tiles: Vec, + pub tile_rects: Vec, +} + +impl RegionTiles { + /// Create a new RegionTiles instance with tiles filtered from the cache + /// for the given bounds and current zoom level. + pub fn new(cache: &ImageTileCache, bounds: &Rectangle, current_zoom: f32) -> Self { + let mut tiles: Vec = Vec::new(); + let mut tile_rects: Vec = Vec::new(); + + const BLUR_SCALE: f32 = 2.0; + const MAX_BLUR_RADIUS: f32 = 16.0; + + // Filter tiles that intersect with the current viewport bounds + for key in cache.filter(bounds) { + let tile_at_zoom = cache.get_tile(key); + let (should_blur, blur_radius, tile_zoom) = if let Some(tile) = tile_at_zoom { + let zoom_diff = current_zoom / tile.zoom; + if zoom_diff > 1.0 + f32::EPSILON { + let blur_radius = ((zoom_diff - 1.0) * BLUR_SCALE).clamp(0.0, MAX_BLUR_RADIUS); + (true, blur_radius, tile.zoom) + } else { + (false, 0.0, tile.zoom) + } + } else { + (false, 0.0, 0.0) + }; + + tiles.push(RegionTileInfo { + key: *key, + blur: should_blur, + blur_radius, + zoom: tile_zoom, + }); + tile_rects.push(key.to_rect()); + } + + // Sort tiles by zoom difference from current zoom (closest to current zoom last) + // This ensures highest quality tiles are drawn on top + tiles.sort_by(|a, b| { + let diff_a = (current_zoom - a.zoom).abs(); + let diff_b = (current_zoom - b.zoom).abs(); + diff_b + .partial_cmp(&diff_a) + .unwrap_or(std::cmp::Ordering::Equal) + }); + + Self { tiles, tile_rects } + } + + /// Get the tile information for rendering + pub fn tiles(&self) -> &[RegionTileInfo] { + &self.tiles + } + + /// Get the tile rectangles for region calculation + pub fn tile_rects(&self) -> &[Rectangle] { + &self.tile_rects + } + + /// Get the number of tiles in this region + pub fn len(&self) -> usize { + self.tiles.len() + } + + /// Check if this region has any tiles + pub fn is_empty(&self) -> bool { + self.tiles.is_empty() + } +} + +/// Simple raster tile cache used by the renderer. +/// +/// This cache maintains two types of tiles: +/// 1. Regular tiles at various zoom levels for current viewport coverage +/// 2. Lowest zoom tiles as explicit fallbacks for zoom out optimization +/// +/// Why we save lowest zoom tiles explicitly: +/// 1. Zoom out optimization: When zooming out, we need tiles at lower zoom levels. +/// Without explicit lowest zoom caching, we'd have no way to optimize zoom out +/// as we'd need to re-render everything at the new zoom level. +/// 2. Memory efficiency: Lowest zoom means fewer tiles to cover the same area, +/// keeping memory usage minimal while providing essential fallback coverage. +/// 3. Progressive quality: Allows smooth transitions between zoom levels by +/// providing immediate fallback tiles while higher quality tiles load. +#[derive(Debug, Clone)] +pub struct ImageTileCache { + tile_size: u16, + tiles: HashMap, + /// The lowest zoom level resolved, even if at least 1 tile exists. + /// This is used to track the most zoomed-out level we've cached, + /// ensuring we always have fallback tiles for zoom out operations. + lowest_zoom: Option, + /// Keys to tiles that are at the lowest zoom level. + /// These tiles are protected from being cleared during zoom changes + /// and serve as essential fallbacks for zoom out optimization. + /// Since lowest zoom means fewer tiles to cover the same area, + /// keeping these in memory is memory-efficient while providing + /// critical coverage for smooth zoom out operations. + lowest_zoom_indices: std::collections::HashSet, + prev_zoom: Option, + zoom_changed_at: Option, + /// Caching is enabled while the camera zoom is at or below this level. + /// When zooming in beyond this value the cache is cleared and disabled + /// as the picture based rendering is fast enough. + pub max_zoom_for_cache: f32, + /// Flag indicating that a full repaint is required to refresh tiles + /// after the cache was cleared due to a zoom change. + needs_full_repaint: bool, +} + +impl Default for ImageTileCache { + fn default() -> Self { + Self { + tile_size: 512, + tiles: HashMap::new(), + lowest_zoom: None, + lowest_zoom_indices: std::collections::HashSet::new(), + prev_zoom: None, + zoom_changed_at: None, + max_zoom_for_cache: 2.0, + needs_full_repaint: false, + } + } +} + +impl ImageTileCache { + pub fn new() -> Self { + Self::default() + } + + pub fn from_size(tile_size: u16) -> Self { + Self { + tile_size, + tiles: HashMap::new(), + lowest_zoom: None, + lowest_zoom_indices: std::collections::HashSet::new(), + prev_zoom: None, + zoom_changed_at: None, + max_zoom_for_cache: 2.0, + needs_full_repaint: false, + } + } + + pub fn with_max_zoom(mut self, max_zoom: f32) -> Self { + self.max_zoom_for_cache = max_zoom; + self + } + + /// Access currently cached raster tiles. + pub fn tiles(&self) -> &HashMap { + &self.tiles + } + + /// Get the lowest zoom level resolved. + pub fn lowest_zoom(&self) -> Option { + self.lowest_zoom + } + + /// Get the keys of tiles at the lowest zoom level. + pub fn lowest_zoom_indices(&self) -> &std::collections::HashSet { + &self.lowest_zoom_indices + } + + /// Get a tile from regular storage only. + /// Lowest zoom tiles are maintained separately for future use. + pub fn get_tile(&self, key: &TileRectKey) -> Option<&TileAtZoom> { + self.tiles.get(key) + } + + /// Get all tiles that intersect with the given bounds (regular tiles only). + /// Returns tiles sorted by zoom level (lowest first) so lower resolution tiles + /// are drawn below higher resolution tiles. + pub fn filter(&self, bounds: &Rectangle) -> Vec<&TileRectKey> { + let mut keys: Vec<&TileRectKey> = self + .tiles + .iter() + .filter_map(|(key, _)| { + if rect::intersects(&key.to_rect(), bounds) { + Some(key) + } else { + None + } + }) + .collect(); + + // Sort by zoom level in ascending order (lowest zoom first) + keys.sort_by(|a, b| { + let zoom_a = self + .tiles + .get(*a) + .map(|tile| tile.zoom) + .unwrap_or(f32::INFINITY); + let zoom_b = self + .tiles + .get(*b) + .map(|tile| tile.zoom) + .unwrap_or(f32::INFINITY); + zoom_a + .partial_cmp(&zoom_b) + .unwrap_or(std::cmp::Ordering::Equal) + }); + + keys + } + + /// Remove all cached tiles (but preserve lowest zoom tiles). + /// + /// Lowest zoom tiles are preserved because: + /// 1. They provide essential fallback coverage for zoom out operations + /// 2. They represent minimal memory usage while covering large areas + /// 3. Without them, zoom out would require expensive re-rendering + pub fn clear(&mut self) { + // Only clear tiles that are not in lowest_zoom_indices + self.tiles + .retain(|key, _| self.lowest_zoom_indices.contains(key)); + self.needs_full_repaint = true; + } + + /// Remove all tiles including lowest zoom tiles. + pub fn clear_all(&mut self) { + self.tiles.clear(); + self.lowest_zoom_indices.clear(); + self.lowest_zoom = None; + self.needs_full_repaint = true; + } + + /// Returns true if the cache should repaint all tiles due to a zoom change + /// that has settled for the debounce duration. + pub fn should_repaint_all(&self) -> bool { + self.zoom_changed_at + .map(|t| t.elapsed() >= CACHE_DEBOUNCE_BY_ZOOM) + .unwrap_or(false) + } + + /// Returns true if a full repaint is required to refresh tiles + pub fn needs_full_repaint(&self) -> bool { + self.needs_full_repaint + } + + /// Mark that the pending full repaint request has been handled + pub fn reset_full_repaint(&mut self) { + self.needs_full_repaint = false; + } + + /// Whether tiles should be cached based on zoom change debounce. + pub fn should_cache_tiles(&self) -> bool { + self.zoom_changed_at + .map(|t| t.elapsed() >= CACHE_DEBOUNCE_BY_ZOOM) + .unwrap_or(true) + } + + /// Notify the cache about a camera zoom change. + pub fn update_zoom(&mut self, zoom: f32) { + if self + .prev_zoom + .map_or(true, |z| (z - zoom).abs() > f32::EPSILON) + { + if zoom > self.max_zoom_for_cache { + // Disable caching when sufficiently zoomed in - picture mode + // is fast enough at this scale. + self.clear(); + self.zoom_changed_at = None; + } else { + // Mark tiles as outdated so they are refreshed after debounce. + // Note: lowest_zoom_indices are preserved + self.zoom_changed_at = Some(Instant::now()); + } + self.prev_zoom = Some(zoom); + } + } + + /// Capture visible region tiles from the provided surface. + pub fn update_tiles( + &mut self, + camera: &Camera2D, + width: f32, + height: f32, + surface: &mut Surface, + mut intersects: F, + ) where + F: FnMut(Rectangle) -> bool, + { + let zoom = camera.get_zoom(); + if zoom > self.max_zoom_for_cache { + // Caching disabled when zoomed in beyond the threshold + self.clear(); + self.zoom_changed_at = None; + return; + } + + let world_size = self.tile_size as f32 / zoom; + let rect = camera.rect(); + + let start_col = (rect.x / world_size).floor() as i32; + let end_col = ((rect.x + rect.width) / world_size).ceil() as i32; + let start_row = (rect.y / world_size).floor() as i32; + let end_row = ((rect.y + rect.height) / world_size).ceil() as i32; + + for col in start_col..end_col { + for row in start_row..end_row { + let world_rect = Rectangle { + x: col as f32 * world_size, + y: row as f32 * world_size, + width: world_size, + height: world_size, + }; + + if !intersects(world_rect) { + continue; + } + + let screen_rect = rect::transform(world_rect, &camera.view_matrix()); + + if screen_rect.x >= 0.0 + && screen_rect.y >= 0.0 + && screen_rect.x + screen_rect.width <= width + && screen_rect.y + screen_rect.height <= height + { + let key = TileRectKey( + world_rect.x.round() as i32, + world_rect.y.round() as i32, + world_rect.width.round() as u32, + world_rect.height.round() as u32, + ); + + // Check if we should update lowest zoom tracking + let should_update_lowest_zoom = if let Some(current_lowest) = self.lowest_zoom { + if zoom < current_lowest { + // New lower zoom level found, clear previous lowest zoom indices + self.lowest_zoom_indices.clear(); + true + } else if (zoom - current_lowest).abs() < f32::EPSILON { + // Same zoom level, add to lowest zoom indices + true + } else { + // Higher zoom level, don't update lowest zoom tracking + false + } + } else { + // No lowest zoom set yet, this becomes the lowest + true + }; + + if should_update_lowest_zoom { + self.lowest_zoom = Some(zoom); + self.lowest_zoom_indices.insert(key); + } + + // Always update regular tiles if they don't exist or if we have a higher zoom (better quality) + let should_update_tile = if let Some(existing_tile) = self.tiles.get(&key) { + // Update if the new tile has higher zoom (better quality) + zoom > existing_tile.zoom + } else { + // No existing tile, so add it + true + }; + + if should_update_tile { + if let Some(image) = surface.image_snapshot_with_bounds(IRect::from_xywh( + screen_rect.x as i32, + screen_rect.y as i32, + self.tile_size as i32, + self.tile_size as i32, + )) { + self.tiles.insert( + key, + TileAtZoom { + image: Rc::new(image), + zoom, + }, + ); + } + } + } + } + } + self.zoom_changed_at = None; + self.needs_full_repaint = false; + } + + /// Get tiles for a specific region with blur information and sorting. + /// This encapsulates the logic for filtering tiles, calculating blur parameters, + /// and sorting by quality for optimal rendering. + pub fn get_region_tiles(&self, bounds: &Rectangle, current_zoom: f32) -> RegionTiles { + RegionTiles::new(self, bounds, current_zoom) + } +} diff --git a/crates/grida-canvas/src/font_loader.rs b/crates/grida-canvas/src/font_loader.rs new file mode 100644 index 0000000000..53c5415a59 --- /dev/null +++ b/crates/grida-canvas/src/font_loader.rs @@ -0,0 +1,167 @@ +use std::collections::HashMap; + +use async_trait::async_trait; + +use crate::resource_loader::ResourceLoader; + +#[cfg(not(target_arch = "wasm32"))] +use reqwest; +#[cfg(not(target_arch = "wasm32"))] +use tokio::sync::mpsc; +#[cfg(not(target_arch = "wasm32"))] +use winit::event_loop::EventLoopProxy; + +/// Represents a font loading mode +#[cfg(not(target_arch = "wasm32"))] +pub enum FontLoadingMode { + /// Simple mode - direct loading without lifecycle management + Simple, + /// Lifecycle mode - full lifecycle management with async loading + Lifecycle { + tx: mpsc::UnboundedSender, + proxy: EventLoopProxy<()>, + }, +} + +#[cfg(target_arch = "wasm32")] +pub enum FontLoadingMode { + /// Simple mode - direct loading without lifecycle management + Simple, +} + +/// Message type for font loading +#[derive(Debug, Clone)] +pub struct FontMessage { + pub family: String, + pub style: Option, + pub data: Vec, +} + +/// Manages font loading and caching +pub struct FontLoader { + mode: FontLoadingMode, + cache: HashMap>, +} + +impl FontLoader { + /// Create a new FontLoader with the specified mode + pub fn new(mode: FontLoadingMode) -> Self { + Self { + mode, + cache: HashMap::new(), + } + } + + /// Create a simple font loader without lifecycle management + pub fn new_simple() -> Self { + Self::new(FontLoadingMode::Simple) + } + + #[cfg(not(target_arch = "wasm32"))] + /// Create a lifecycle-based font loader + pub fn new_lifecycle( + tx: mpsc::UnboundedSender, + proxy: EventLoopProxy<()>, + ) -> Self { + Self::new(FontLoadingMode::Lifecycle { tx, proxy }) + } + + /// Load a font from a URL or file path + pub async fn load_font(&mut self, family: &str, src: &str) -> Option> { + self.load_font_with_style(family, None, src).await + } + + /// Load a font from a URL or file path with style information + pub async fn load_font_with_style( + &mut self, + family: &str, + style: Option<&str>, + src: &str, + ) -> Option> { + // Check cache first + let cache_key = if let Some(style) = style { + format!("{}:{}", family, style) + } else { + family.to_string() + }; + + if let Some(data) = self.cache.get(&cache_key) { + return Some(data.clone()); + } + + // Load the font + let data = match self.fetch_font_data(src).await { + Ok(data) => data, + Err(e) => { + eprintln!("Failed to load font {}: {}", src, e); + return None; + } + }; + + // Cache the data + self.cache.insert(cache_key, data.clone()); + + // If in lifecycle mode, send the font data through the channel + #[cfg(not(target_arch = "wasm32"))] + if let FontLoadingMode::Lifecycle { tx, proxy } = &self.mode { + let _ = tx.send(FontMessage { + family: family.to_string(), + style: style.map(|s| s.to_string()), + data: data.clone(), + }); + let _ = proxy.send_event(()); + } + + Some(data) + } + + /// Fetch font data from URL or file + async fn fetch_font_data(&self, path: &str) -> Result, Box> { + #[cfg(not(target_arch = "wasm32"))] + if path.starts_with("http") { + let response = reqwest::get(path).await?; + Ok(response.bytes().await?.to_vec()) + } else { + Ok(std::fs::read(path)?) + } + + #[cfg(target_arch = "wasm32")] + Err("Font loading not supported in wasm".into()) + } + + /// Clear the font cache + pub fn clear_cache(&mut self) { + self.cache.clear(); + } + + /// Remove a specific font from the cache + pub fn remove_from_cache(&mut self, family: &str) { + self.cache.remove(family); + } +} + +#[async_trait] +impl ResourceLoader for FontLoader { + type Output = Vec; + + async fn load(&mut self, key: &str, src: &str) -> Option { + self.load_font(key, src).await + } + + async fn unload(&mut self, key: &str) { + self.remove_from_cache(key); + } +} + +#[cfg(not(target_arch = "wasm32"))] +pub async fn load_font(path: &str) -> Result, Box> { + let response = reqwest::get(path).await?; + let bytes = response.bytes().await?; + Ok(bytes.to_vec()) +} + +#[cfg(target_arch = "wasm32")] +pub async fn load_font(path: &str) -> Result, Box> { + // Stub for wasm + Err("Font loading not supported in wasm".into()) +} diff --git a/crates/grida-canvas/src/hit_test.rs b/crates/grida-canvas/src/hit_test.rs new file mode 100644 index 0000000000..706a14db26 --- /dev/null +++ b/crates/grida-canvas/src/hit_test.rs @@ -0,0 +1,80 @@ +use crate::cache::scene::SceneCache; +use crate::node::schema::NodeId; +use crate::painter::layer::Layer; +use math2::rect; +use math2::vector2::Vector2; + +/// Hit testing utilities for [`SceneCache`]. +/// +/// This module implements a simple geometry based hit tester. It queries +/// [`GeometryCache`] bounds stored inside a [`SceneCache`] and returns the node +/// identifiers that intersect a screen point. +/// +/// Hit testing happens in a few steps: +/// 1. Filter nodes whose render bounds contain the point +/// 2. Sort the filtered nodes by z-index (which reflects tree order) +/// 3. Return the first match (path level checks TBD) +/// +/// The sorted order mirrors DOM hit testing behaviour where the deepest node is +/// evaluated first. Step three is left as a TODO until more reliable path +/// testing is implemented. +#[derive(Debug)] +pub struct HitTester<'a> { + cache: &'a SceneCache, +} + +impl<'a> HitTester<'a> { + /// Create a new [`HitTester`] backed by the given scene cache. + pub fn new(cache: &'a SceneCache) -> Self { + Self { cache } + } + + /// Returns the top-most node containing the point, if any. + /// + /// Layers are checked from deepest to shallowest, so the first match mimics + /// DOM hit testing semantics. This stops as soon as a match is found, + /// making it faster when only one result is needed. + pub fn hit_first(&self, point: Vector2) -> Option { + let mut indices = self.cache.intersects_point(point); + indices.sort(); + for idx in indices.into_iter().rev() { + let layer = &self.cache.layers.layers[idx]; + if let Some(bounds) = self.cache.geometry.get_render_bounds(layer.id()) { + if rect::contains_point(&bounds, point) { + // TODO: perform precise path hit testing + return Some(layer.id().clone()); + } + } + } + None + } + + /// Returns all nodes containing the point ordered from top to bottom. + /// + /// The returned vector is sorted from deepest to shallowest, mirroring how + /// events bubble in typical DOM systems. + pub fn hits(&self, point: Vector2) -> Vec { + let mut indices = self.cache.intersects_point(point); + indices.sort(); + let mut out = Vec::with_capacity(indices.len()); + for idx in indices.into_iter().rev() { + let layer = &self.cache.layers.layers[idx]; + if let Some(bounds) = self.cache.geometry.get_render_bounds(layer.id()) { + if rect::contains_point(&bounds, point) { + out.push(layer.id().clone()); + } + } + } + out + } + + /// Returns `true` if the specified node contains the point within its + /// render bounds. + pub fn contains(&self, id: &NodeId, point: Vector2) -> bool { + self.cache + .geometry + .get_render_bounds(id) + .map(|b| rect::contains_point(&b, point)) + .unwrap_or(false) + } +} diff --git a/crates/grida-canvas/src/image_loader.rs b/crates/grida-canvas/src/image_loader.rs new file mode 100644 index 0000000000..69c1b0b9b1 --- /dev/null +++ b/crates/grida-canvas/src/image_loader.rs @@ -0,0 +1,179 @@ +use crate::node::schema::*; +use std::collections::HashMap; + +use async_trait::async_trait; + +use crate::resource_loader::ResourceLoader; + +#[cfg(not(target_arch = "wasm32"))] +use reqwest; +#[cfg(not(target_arch = "wasm32"))] +use tokio::sync::mpsc; +#[cfg(not(target_arch = "wasm32"))] +use winit::event_loop::EventLoopProxy; + +/// Represents an image loading mode +#[cfg(not(target_arch = "wasm32"))] +pub enum ImageLoadingMode { + /// Simple mode - direct loading without lifecycle management + Simple, + /// Lifecycle mode - full lifecycle management with async loading + Lifecycle { + tx: mpsc::UnboundedSender, + proxy: EventLoopProxy<()>, + }, +} + +#[cfg(target_arch = "wasm32")] +pub enum ImageLoadingMode { + /// Simple mode - direct loading without lifecycle management + Simple, +} + +/// Message type for image loading +#[derive(Debug, Clone)] +pub struct ImageMessage { + pub src: String, + pub data: Vec, +} + +/// Manages image loading and caching +pub struct ImageLoader { + mode: ImageLoadingMode, + cache: HashMap>, +} + +impl ImageLoader { + /// Create a new ImageLoader with the specified mode + pub fn new(mode: ImageLoadingMode) -> Self { + Self { + mode, + cache: HashMap::new(), + } + } + + /// Create a simple image loader without lifecycle management + pub fn new_simple() -> Self { + Self::new(ImageLoadingMode::Simple) + } + + #[cfg(not(target_arch = "wasm32"))] + /// Create a lifecycle-based image loader + pub fn new_lifecycle( + tx: mpsc::UnboundedSender, + proxy: EventLoopProxy<()>, + ) -> Self { + Self::new(ImageLoadingMode::Lifecycle { tx, proxy }) + } + + /// Load an image from a URL or file path + pub async fn load_image(&mut self, src: &str) -> Option> { + // Check cache first + if let Some(data) = self.cache.get(src) { + return Some(data.clone()); + } + + // Load the image + let data = match self.fetch_image_data(src).await { + Ok(data) => data, + Err(_e) => { + // eprintln!("Failed to load image {}: {}", src, e); + return None; + } + }; + + // Cache the data + self.cache.insert(src.to_string(), data.clone()); + + // If in lifecycle mode, send the image data through the channel + #[cfg(not(target_arch = "wasm32"))] + if let ImageLoadingMode::Lifecycle { tx, proxy } = &self.mode { + let _ = tx.send(ImageMessage { + src: src.to_string(), + data: data.clone(), + }); + let _ = proxy.send_event(()); + } + + Some(data) + } + + /// Fetch image data from URL or file + async fn fetch_image_data(&self, path: &str) -> Result, Box> { + #[cfg(not(target_arch = "wasm32"))] + if path.starts_with("http") { + let response = reqwest::get(path).await?; + Ok(response.bytes().await?.to_vec()) + } else { + Ok(std::fs::read(path)?) + } + + #[cfg(target_arch = "wasm32")] + Err("Image loading not supported in wasm".into()) + } + + /// Clear the image cache + pub fn clear_cache(&mut self) { + self.cache.clear(); + } + + /// Remove a specific image from the cache + pub fn remove_from_cache(&mut self, src: &str) { + self.cache.remove(src); + } +} + +#[async_trait] +impl ResourceLoader for ImageLoader { + type Output = Vec; + + async fn load(&mut self, key: &str, src: &str) -> Option { + // For images, the key and src are generally the same + let path = if src.is_empty() { key } else { src }; + self.load_image(path).await + } + + async fn unload(&mut self, key: &str) { + self.remove_from_cache(key); + } +} + +/// Helper function to extract image URLs from a scene +pub fn extract_image_urls(scene: &Scene) -> Vec { + scene + .nodes + .iter() + .filter_map(|(_, n)| match n { + Node::Rectangle(rect) => match (&rect.fill, &rect.stroke) { + (Paint::Image(img), _) => Some(img._ref.clone()), + (_, Paint::Image(img)) => Some(img._ref.clone()), + _ => None, + }, + _ => None, + }) + .collect() +} + +/// Helper function to load all images in a scene +pub async fn load_scene_images(loader: &mut L, scene: &Scene) +where + L: ResourceLoader> + Send, +{ + let urls = extract_image_urls(scene); + for url in urls { + let _ = loader.load(&url, &url).await; + } +} + +#[cfg(not(target_arch = "wasm32"))] +pub async fn load_image(path: &str) -> Result, Box> { + let response = reqwest::get(path).await?; + let bytes = response.bytes().await?; + Ok(bytes.to_vec()) +} + +#[cfg(target_arch = "wasm32")] +pub async fn load_image(path: &str) -> Result, Box> { + // Stub for wasm + Err("Image loading not supported in wasm".into()) +} diff --git a/crates/grida-canvas/src/io/io_figma.rs b/crates/grida-canvas/src/io/io_figma.rs new file mode 100644 index 0000000000..5cb7a7623f --- /dev/null +++ b/crates/grida-canvas/src/io/io_figma.rs @@ -0,0 +1,1294 @@ +use crate::node::repository::NodeRepository; +use crate::node::schema::{ + BaseNode, BlendMode, BooleanPathOperation, BooleanPathOperationNode, Color, ContainerNode, + EllipseNode, ErrorNode, FeBackdropBlur, FeDropShadow, FeGaussianBlur, FilterEffect, FontWeight, + GradientStop, ImagePaint, LineNode, LinearGradientPaint, Node, NodeId, Paint, PathNode, + RadialGradientPaint, RectangleNode, RectangularCornerRadius, RegularPolygonNode, + RegularStarPolygonNode, Scene, Size, SolidPaint, StrokeAlign, TextAlign, TextAlignVertical, + TextDecoration, TextSpanNode, TextStyle, TextTransform, +}; +use crate::webfont_helper; +use figma_api::models::minimal_strokes_trait::StrokeAlign as FigmaStrokeAlign; +use figma_api::models::type_style::{ + TextAlignHorizontal as FigmaTextAlignHorizontal, TextAlignVertical as FigmaTextAlignVertical, + TextDecoration as FigmaTextDecoration, +}; +use figma_api::models::vector::Vector; +use figma_api::models::{ + BooleanOperationNode as FigmaBooleanOperationNode, CanvasNode, ComponentNode, ComponentSetNode, + DocumentNode, Effect, FrameNode, GroupNode, InstanceNode, LineNode as FigmaLineNode, + LinkUnfurlNode, Paint as FigmaPaint, RectangleNode as FigmaRectangleNode, + RegularPolygonNode as FigmaRegularPolygonNode, Rgba, SectionNode, SliceNode, StarNode, + SubcanvasNode as FigmaSubcanvasNode, TextNode, VectorNode, +}; +use math2::box_fit::BoxFit; +use math2::transform::AffineTransform; + +const TRANSPARENT: Paint = Paint::Solid(SolidPaint { + color: Color(0, 0, 0, 0), + opacity: 0.0, +}); + +const BLACK: Paint = Paint::Solid(SolidPaint { + color: Color(0, 0, 0, 255), + opacity: 1.0, +}); + +// Map implementations +impl From<&Rgba> for Color { + fn from(color: &Rgba) -> Self { + Color( + (color.r * 255.0) as u8, + (color.g * 255.0) as u8, + (color.b * 255.0) as u8, + (color.a * 255.0) as u8, + ) + } +} + +impl From<&Box> for Color { + fn from(color: &Box) -> Self { + Color( + (color.r * 255.0) as u8, + (color.g * 255.0) as u8, + (color.b * 255.0) as u8, + (color.a * 255.0) as u8, + ) + } +} + +impl From<&FigmaPaint> for Paint { + fn from(paint: &FigmaPaint) -> Self { + match paint { + FigmaPaint::SolidPaint(solid) => Paint::Solid(SolidPaint { + color: Color::from(&solid.color), + opacity: solid.opacity.unwrap_or(1.0) as f32, + }), + FigmaPaint::ImagePaint(image) => { + let transform = + image + .image_transform + .as_ref() + .map_or(AffineTransform::identity(), |t| AffineTransform { + matrix: [ + [t[0][0] as f32, t[0][1] as f32, t[0][2] as f32], + [t[1][0] as f32, t[1][1] as f32, t[1][2] as f32], + ], + }); + + let fit = if transform != AffineTransform::identity() { + BoxFit::None + } else { + match image.scale_mode { + figma_api::models::image_paint::ScaleMode::Fill => BoxFit::Cover, + figma_api::models::image_paint::ScaleMode::Fit => BoxFit::Contain, + figma_api::models::image_paint::ScaleMode::Tile => BoxFit::None, + figma_api::models::image_paint::ScaleMode::Stretch => BoxFit::None, + } + }; + + Paint::Image(ImagePaint { + transform, + _ref: image.image_ref.clone(), + fit, + opacity: image.opacity.unwrap_or(1.0) as f32, + }) + } + FigmaPaint::GradientPaint(gradient) => { + let stops = gradient + .gradient_stops + .iter() + .map(|stop| GradientStop { + offset: stop.position as f32, + color: Color::from(&stop.color), + }) + .collect(); + + match gradient.r#type { + figma_api::models::gradient_paint::Type::GradientLinear => { + Paint::LinearGradient(LinearGradientPaint { + transform: convert_gradient_transform( + &gradient.gradient_handle_positions, + ), + stops, + opacity: gradient.opacity.unwrap_or(1.0) as f32, + }) + } + figma_api::models::gradient_paint::Type::GradientRadial => { + Paint::RadialGradient(RadialGradientPaint { + transform: convert_gradient_transform( + &gradient.gradient_handle_positions, + ), + stops, + opacity: gradient.opacity.unwrap_or(1.0) as f32, + }) + } + _ => Paint::Solid(SolidPaint { + color: Color(0, 0, 0, 255), + opacity: 1.0, + }), + } + } + _ => Paint::Solid(SolidPaint { + color: Color(0, 0, 0, 255), + opacity: 1.0, + }), + } + } +} + +impl From<&FigmaStrokeAlign> for StrokeAlign { + fn from(align: &FigmaStrokeAlign) -> Self { + match align { + FigmaStrokeAlign::Inside => StrokeAlign::Inside, + FigmaStrokeAlign::Outside => StrokeAlign::Outside, + FigmaStrokeAlign::Center => StrokeAlign::Center, + } + } +} + +impl From<&FigmaTextAlignHorizontal> for TextAlign { + fn from(align: &FigmaTextAlignHorizontal) -> Self { + match align { + FigmaTextAlignHorizontal::Left => TextAlign::Left, + FigmaTextAlignHorizontal::Center => TextAlign::Center, + FigmaTextAlignHorizontal::Right => TextAlign::Right, + FigmaTextAlignHorizontal::Justified => TextAlign::Justify, + } + } +} + +impl From<&FigmaTextAlignVertical> for TextAlignVertical { + fn from(align: &FigmaTextAlignVertical) -> Self { + match align { + FigmaTextAlignVertical::Top => TextAlignVertical::Top, + FigmaTextAlignVertical::Center => TextAlignVertical::Center, + FigmaTextAlignVertical::Bottom => TextAlignVertical::Bottom, + } + } +} + +impl From<&FigmaTextDecoration> for TextDecoration { + fn from(decoration: &FigmaTextDecoration) -> Self { + match decoration { + FigmaTextDecoration::None => TextDecoration::None, + FigmaTextDecoration::Underline => TextDecoration::Underline, + FigmaTextDecoration::Strikethrough => TextDecoration::LineThrough, + } + } +} + +fn map_option<'a, T, U>(value: Option<&'a T>) -> Option +where + U: From<&'a T>, +{ + value.map(|v| U::from(v)) +} + +/// Convert Figma gradient handle positions into an AffineTransform. +/// +/// Figma provides three handle positions in normalized coordinates. The first +/// handle represents the start of the gradient, the second the end, and the +/// third defines the width of the gradient. We convert these into a 2x3 affine +/// transform matrix. +fn convert_gradient_transform(handles: &Vec) -> AffineTransform { + if handles.len() == 3 { + let start = &handles[0]; + let end = &handles[1]; + let width = &handles[2]; + + AffineTransform { + matrix: [ + [ + (end.x - start.x) as f32, + (width.x - start.x) as f32, + start.x as f32, + ], + [ + (end.y - start.y) as f32, + (width.y - start.y) as f32, + start.y as f32, + ], + ], + } + } else { + AffineTransform::identity() + } +} + +/// Converts Figma nodes to Grida schema +pub struct FigmaConverter { + repository: NodeRepository, + image_urls: std::collections::HashMap, + font_store: webfont_helper::FontUsageStore, +} + +impl FigmaConverter { + pub fn new() -> Self { + Self { + repository: NodeRepository::new(), + image_urls: std::collections::HashMap::new(), + font_store: webfont_helper::FontUsageStore::new(), + } + } + + pub fn with_image_urls(mut self, urls: std::collections::HashMap) -> Self { + self.image_urls = urls; + self + } + + pub fn get_discovered_fonts(&self) -> Vec { + self.font_store.get_discovered_fonts() + } + + fn register_font( + &mut self, + family: String, + postscript_name: Option, + style: Option, + ) { + self.font_store + .register_font(family, postscript_name, style); + } + + /// Convert Figma's relative transform matrix to AffineTransform + fn convert_transform(relative_transform: Option<&Vec>>) -> AffineTransform { + relative_transform.map_or(AffineTransform::identity(), |transform| { + // Convert Figma's 2x3 transform matrix to AffineTransform + // Figma matrix: [a c tx] + // [b d ty] + AffineTransform { + matrix: [ + [ + transform[0][0] as f32, + transform[0][1] as f32, + transform[0][2] as f32, + ], + [ + transform[1][0] as f32, + transform[1][1] as f32, + transform[1][2] as f32, + ], + ], + } + }) + } + + /// Convert Figma's RGBA color to our Color + fn convert_color(color: &Rgba) -> Color { + color.into() + } + + /// Convert Figma's paint to our Paint + fn convert_paint(&self, paint: &FigmaPaint) -> Paint { + match paint { + FigmaPaint::SolidPaint(solid) => Paint::Solid(SolidPaint { + color: Color::from(&solid.color), + opacity: solid.opacity.unwrap_or(1.0) as f32, + }), + FigmaPaint::ImagePaint(image) => { + let url = self + .image_urls + .get(&image.image_ref) + .cloned() + .unwrap_or_else(|| image.image_ref.clone()); + let transform = + image + .image_transform + .as_ref() + .map_or(AffineTransform::identity(), |t| AffineTransform { + matrix: [ + [t[0][0] as f32, t[0][1] as f32, t[0][2] as f32], + [t[1][0] as f32, t[1][1] as f32, t[1][2] as f32], + ], + }); + + let fit = if transform != AffineTransform::identity() { + BoxFit::None + } else { + match image.scale_mode { + figma_api::models::image_paint::ScaleMode::Fill => BoxFit::Cover, + figma_api::models::image_paint::ScaleMode::Fit => BoxFit::Contain, + figma_api::models::image_paint::ScaleMode::Tile => BoxFit::None, + figma_api::models::image_paint::ScaleMode::Stretch => BoxFit::None, + } + }; + + Paint::Image(ImagePaint { + transform, + _ref: url, + fit, + opacity: image.opacity.unwrap_or(1.0) as f32, + }) + } + FigmaPaint::GradientPaint(gradient) => { + let stops = gradient + .gradient_stops + .iter() + .map(|stop| GradientStop { + offset: stop.position as f32, + color: Color::from(&stop.color), + }) + .collect(); + + match gradient.r#type { + figma_api::models::gradient_paint::Type::GradientLinear => { + Paint::LinearGradient(LinearGradientPaint { + transform: convert_gradient_transform( + &gradient.gradient_handle_positions, + ), + stops, + opacity: gradient.opacity.unwrap_or(1.0) as f32, + }) + } + figma_api::models::gradient_paint::Type::GradientRadial => { + Paint::RadialGradient(RadialGradientPaint { + transform: convert_gradient_transform( + &gradient.gradient_handle_positions, + ), + stops, + opacity: gradient.opacity.unwrap_or(1.0) as f32, + }) + } + _ => Paint::Solid(SolidPaint { + color: Color(0, 0, 0, 255), + opacity: 1.0, + }), + } + } + _ => Paint::Solid(SolidPaint { + color: Color(0, 0, 0, 255), + opacity: 1.0, + }), + } + } + + /// Convert Figma's fills to our Paint + fn convert_fills(&self, fills: Option<&Vec>) -> Option { + fills.and_then(|paints| { + // Filter out invisible paints and get the first visible one + paints + .iter() + .filter(|paint| match paint { + FigmaPaint::SolidPaint(solid) => solid.visible.unwrap_or(true), + FigmaPaint::GradientPaint(gradient) => gradient.visible.unwrap_or(true), + FigmaPaint::ImagePaint(image) => image.visible.unwrap_or(true), + _ => true, + }) + .next() + .map(|paint| self.convert_paint(paint)) + }) + } + + /// Convert Figma's strokes to our Paint + fn convert_strokes(&self, strokes: Option<&Option>>) -> Option { + strokes.and_then(|s| s.as_ref()).and_then(|paints| { + // Filter out invisible paints and get the first visible one + paints + .iter() + .filter(|paint| match paint { + FigmaPaint::SolidPaint(solid) => solid.visible.unwrap_or(true), + FigmaPaint::GradientPaint(gradient) => gradient.visible.unwrap_or(true), + FigmaPaint::ImagePaint(image) => image.visible.unwrap_or(true), + _ => true, + }) + .next() + .map(|paint| self.convert_paint(paint)) + }) + } + + /// Convert Figma's stroke align to our StrokeAlign + fn convert_stroke_align(stroke_align: String) -> StrokeAlign { + match stroke_align.as_str() { + "INSIDE" => StrokeAlign::Inside, + "OUTSIDE" => StrokeAlign::Outside, + "CENTER" => StrokeAlign::Center, + _ => StrokeAlign::Center, + } + } + + /// Convert Figma's Vector to our Size + fn convert_size(size: Option<&Box>) -> Size { + size.map_or( + Size { + width: 0.0, + height: 0.0, + }, + |size| Size { + width: size.x as f32, + height: size.y as f32, + }, + ) + } + + /// Convert Figma's visibility to opacity (1.0 if visible, 0.0 if not) + fn convert_opacity(visible: Option) -> f32 { + visible.unwrap_or(true).then_some(1.0).unwrap_or(0.0) + } + + /// Convert Figma's text decoration to our TextDecoration + fn convert_text_decoration(decoration: Option<&FigmaTextDecoration>) -> TextDecoration { + map_option(decoration).unwrap_or(TextDecoration::None) + } + + /// Convert Figma's text alignment to our TextAlign + fn convert_text_align(align: Option<&FigmaTextAlignHorizontal>) -> TextAlign { + map_option(align).unwrap_or(TextAlign::Left) + } + + /// Convert Figma's vertical text alignment to our TextAlignVertical + fn convert_text_align_vertical(align: Option<&FigmaTextAlignVertical>) -> TextAlignVertical { + map_option(align).unwrap_or(TextAlignVertical::Top) + } + + /// Convert Figma's effects to our FilterEffect + fn convert_effects(effects: Option<&Vec>) -> Option { + // If no effects, return None + let effects = effects?; + if effects.is_empty() { + return None; + } + + // Filter visible effects first + let visible_effects: Vec<&Effect> = effects + .iter() + .filter(|effect| match effect { + Effect::DropShadow(drop_shadow) => drop_shadow.visible, + Effect::LayerBlur(blur) => blur.visible, + Effect::BackgroundBlur(blur) => blur.visible, + Effect::InnerShadow(inner_shadow) => inner_shadow.visible, + _ => true, + }) + .collect(); + + // Find the first valid effect + for effect in visible_effects { + match effect { + Effect::DropShadow(drop_shadow) => { + return Some(FilterEffect::DropShadow(FeDropShadow { + dx: drop_shadow.offset.x as f32, + dy: drop_shadow.offset.y as f32, + blur: drop_shadow.radius as f32, + color: Self::convert_color(&drop_shadow.color), + })); + } + Effect::LayerBlur(blur) => { + return Some(FilterEffect::GaussianBlur(FeGaussianBlur { + radius: blur.radius as f32, + })); + } + Effect::BackgroundBlur(blur) => { + return Some(FilterEffect::BackdropBlur(FeBackdropBlur { + radius: blur.radius as f32, + })); + } + _ => continue, // Skip unsupported effects + } + } + + None // No valid effects found + } + + /// Convert Figma's slice to our SliceNode + fn convert_slice(&mut self, slice: &Box) -> Result { + Ok(Node::Error(ErrorNode { + base: BaseNode { + id: slice.id.clone(), + name: format!("[Slice] {}", slice.name), + active: slice.visible.unwrap_or(true), + }, + transform: AffineTransform::identity(), + size: Size { + width: 100.0, + height: 100.0, + }, + opacity: Self::convert_opacity(slice.visible), + error: format!("Unsupported node type: Slice"), + })) + } + + /// Convert Figma's component to our ComponentNode + fn convert_component(&mut self, component: &Box) -> Result { + // Since ComponentNode inherits from FrameNode, we can reuse convert_frame + // by creating a FrameNode with the instance's properties (at the moment, we're mapping it manually) + + let children = component + .children + .iter() + .map(|child| self.convert_sub_canvas_node(child)) + .collect::, _>>()?; + + let size = Self::convert_size(component.size.as_ref()); + let transform = Self::convert_transform(component.relative_transform.as_ref()); + + Ok(Node::Container(ContainerNode { + base: BaseNode { + id: component.id.clone(), + name: component.name.clone(), + active: component.visible.unwrap_or(true), + }, + blend_mode: Self::convert_blend_mode(component.blend_mode), + transform, + size, + corner_radius: Self::convert_corner_radius( + component.corner_radius, + component.rectangle_corner_radii.as_ref(), + ), + fill: self + .convert_fills(Some(&component.fills.as_ref())) + .unwrap_or(TRANSPARENT), + stroke: self.convert_strokes(Some(&component.strokes)), + stroke_width: component.stroke_weight.unwrap_or(0.0) as f32, + stroke_align: Self::convert_stroke_align( + component + .stroke_align + .as_ref() + .map(|a| serde_json::to_string(a).unwrap_or_default()) + .unwrap_or_else(|| "CENTER".to_string()), + ), + stroke_dash_array: component + .stroke_dashes + .clone() + .map(|v| v.into_iter().map(|x| x as f32).collect()), + effect: Self::convert_effects(Some(&component.effects)), + children, + opacity: Self::convert_opacity(component.visible), + clip: component.clips_content, + })) + } + + /// Convert Figma's component set to our ComponentSetNode + fn convert_component_set( + &mut self, + component_set: &Box, + ) -> Result { + Ok(Node::Error(ErrorNode { + base: BaseNode { + id: component_set.id.clone(), + name: format!("[ComponentSet] {}", component_set.name), + active: component_set.visible.unwrap_or(true), + }, + transform: Self::convert_transform(component_set.relative_transform.as_ref()), + size: Self::convert_size(component_set.size.as_ref()), + opacity: Self::convert_opacity(component_set.visible), + error: format!("Unsupported node type: ComponentSet"), + })) + } + + /// Convert Figma's corner radii array to our RectangularCornerRadius + fn convert_corner_radius( + corner_radius: Option, + rectangle_corner_radii: Option<&Vec>, + ) -> RectangularCornerRadius { + if let Some(radius) = corner_radius { + // If corner_radius is present, use it for all corners + RectangularCornerRadius { + tl: radius as f32, + tr: radius as f32, + br: radius as f32, + bl: radius as f32, + } + } else if let Some(radii) = rectangle_corner_radii { + // If rectangle_corner_radii is present, use individual values + if radii.len() == 4 { + RectangularCornerRadius { + tl: radii[0] as f32, + tr: radii[1] as f32, + br: radii[2] as f32, + bl: radii[3] as f32, + } + } else { + RectangularCornerRadius::zero() + } + } else { + // If neither is present, return zero radius + RectangularCornerRadius::zero() + } + } + + /// Convert Figma's instance to our InstanceNode + fn convert_instance(&mut self, instance: &Box) -> Result { + // Since InstanceNode inherits from FrameNode, we can reuse convert_frame + // by creating a FrameNode with the instance's properties (at the moment, we're mapping it manually) + + let children = instance + .children + .iter() + .map(|child| self.convert_sub_canvas_node(child)) + .collect::, _>>()?; + + let size = Self::convert_size(instance.size.as_ref()); + let transform = Self::convert_transform(instance.relative_transform.as_ref()); + + Ok(Node::Container(ContainerNode { + base: BaseNode { + id: instance.id.clone(), + name: instance.name.clone(), + active: instance.visible.unwrap_or(true), + }, + blend_mode: Self::convert_blend_mode(instance.blend_mode), + transform, + size, + corner_radius: Self::convert_corner_radius( + instance.corner_radius, + instance.rectangle_corner_radii.as_ref(), + ), + fill: self + .convert_fills(Some(&instance.fills.as_ref())) + .unwrap_or(TRANSPARENT), + stroke: self.convert_strokes(Some(&instance.strokes)), + stroke_width: instance.stroke_weight.unwrap_or(0.0) as f32, + stroke_align: Self::convert_stroke_align( + instance + .stroke_align + .as_ref() + .map(|a| serde_json::to_string(a).unwrap_or_default()) + .unwrap_or_else(|| "CENTER".to_string()), + ), + stroke_dash_array: instance + .stroke_dashes + .clone() + .map(|v| v.into_iter().map(|x| x as f32).collect()), + effect: Self::convert_effects(Some(&instance.effects)), + children, + opacity: Self::convert_opacity(instance.visible), + clip: instance.clips_content, + })) + } + + /// Convert Figma's section to our SectionNode + fn convert_section(&mut self, section: &Box) -> Result { + let children = section + .children + .iter() + .map(|child| self.convert_sub_canvas_node(child)) + .collect::, _>>()?; + + Ok(Node::Container(ContainerNode { + base: BaseNode { + id: section.id.clone(), + name: format!("[Section] {}", section.name), + active: section.visible.unwrap_or(true), + }, + blend_mode: BlendMode::Normal, + transform: Self::convert_transform(section.relative_transform.as_ref()), + size: Self::convert_size(section.size.as_ref()), + corner_radius: RectangularCornerRadius::zero(), + children, + fill: self + .convert_fills(Some(§ion.fills.as_ref())) + .unwrap_or(TRANSPARENT), + stroke: None, + stroke_width: 0.0, + stroke_align: StrokeAlign::Inside, + stroke_dash_array: None, + opacity: Self::convert_opacity(section.visible), + effect: None, + clip: false, + })) + } + + /// Convert Figma's link to our LinkUnfurlNode + fn convert_link(&mut self, link: &Box) -> Result { + Ok(Node::Error(ErrorNode { + base: BaseNode { + id: link.id.clone(), + name: format!("[Link] {}", link.name), + active: link.visible.unwrap_or(true), + }, + transform: AffineTransform::identity(), + size: Size { + width: 100.0, + height: 100.0, + }, + opacity: Self::convert_opacity(link.visible), + error: format!("Unsupported node type: Link"), + })) + } + + /// Convert Figma's node to Grida schema + pub fn convert_sub_canvas_node(&mut self, node: &FigmaSubcanvasNode) -> Result { + let grid_node = match node { + FigmaSubcanvasNode::Frame(frame) => self.convert_frame(frame)?, + FigmaSubcanvasNode::Group(group) => self.convert_group(group)?, + FigmaSubcanvasNode::Vector(vector) => self.convert_vector(vector)?, + FigmaSubcanvasNode::BooleanOperation(boolean) => { + self.convert_boolean_operation(boolean)? + } + FigmaSubcanvasNode::Star(star) => self.convert_star(star)?, + FigmaSubcanvasNode::Line(line) => self.convert_line(line)?, + FigmaSubcanvasNode::Ellipse(ellipse) => self.convert_ellipse(ellipse)?, + FigmaSubcanvasNode::RegularPolygon(polygon) => self.convert_regular_polygon(polygon)?, + FigmaSubcanvasNode::Rectangle(rectangle) => self.convert_rectangle(rectangle)?, + FigmaSubcanvasNode::Text(text) => self.convert_text(text)?, + FigmaSubcanvasNode::Slice(slice) => self.convert_slice(slice)?, + FigmaSubcanvasNode::Component(component) => self.convert_component(component)?, + FigmaSubcanvasNode::ComponentSet(component_set) => { + self.convert_component_set(component_set)? + } + FigmaSubcanvasNode::Instance(instance) => self.convert_instance(instance)?, + FigmaSubcanvasNode::Section(section) => self.convert_section(section)?, + FigmaSubcanvasNode::LinkUnfurl(link) => self.convert_link(link)?, + FigmaSubcanvasNode::Connector(_) => Err("Connector nodes not supported".to_string())?, + FigmaSubcanvasNode::Embed(_) => Err("Embed nodes not supported".to_string())?, + FigmaSubcanvasNode::ShapeWithText(_) => { + Err("Shape with text nodes not supported".to_string())? + } + FigmaSubcanvasNode::Sticky(_) => Err("Sticky nodes not supported".to_string())?, + FigmaSubcanvasNode::TableCell(_) => Err("Table cell nodes not supported".to_string())?, + FigmaSubcanvasNode::Table(_) => Err("Table nodes not supported".to_string())?, + FigmaSubcanvasNode::WashiTape(_) => Err("Washi tape nodes not supported".to_string())?, + FigmaSubcanvasNode::Widget(_) => Err("Widget nodes not supported".to_string())?, + FigmaSubcanvasNode::TextPath(_) => Err("Text path nodes not supported".to_string())?, + FigmaSubcanvasNode::TransformGroup(_) => { + Err("Transform group nodes not supported".to_string())? + } + }; + + Ok(self.repository.insert(grid_node)) + } + + pub fn convert_document(&mut self, document: &Box) -> Result, String> { + document + .children + .iter() + .map(|canvas| self.convert_canvas(canvas)) + .collect::, _>>() + } + + fn convert_canvas(&mut self, canvas: &CanvasNode) -> Result { + let children = canvas + .children + .iter() + .map(|child| self.convert_sub_canvas_node(child)) + .collect::, _>>()?; + // canvas.background_color + Ok(Scene { + id: canvas.id.clone(), + name: canvas.name.clone(), + transform: AffineTransform::identity(), + children, + nodes: self.repository.clone(), + background_color: Some(Color::from(&canvas.background_color)), + }) + } + + fn convert_frame(&mut self, origin: &Box) -> Result { + let children = origin + .children + .iter() + .map(|child| self.convert_sub_canvas_node(child)) + .collect::, _>>()?; + + let size = Self::convert_size(origin.size.as_ref()); + let transform = Self::convert_transform(origin.relative_transform.as_ref()); + + Ok(Node::Container(ContainerNode { + base: BaseNode { + id: origin.id.clone(), + name: origin.name.clone(), + active: origin.visible.unwrap_or(true), + }, + blend_mode: Self::convert_blend_mode(origin.blend_mode), + transform, + size, + corner_radius: Self::convert_corner_radius( + origin.corner_radius, + origin.rectangle_corner_radii.as_ref(), + ), + fill: self + .convert_fills(Some(&origin.fills.as_ref())) + .unwrap_or(TRANSPARENT), + stroke: self.convert_strokes(Some(&origin.strokes)), + stroke_width: origin.stroke_weight.unwrap_or(0.0) as f32, + stroke_align: Self::convert_stroke_align( + origin + .stroke_align + .as_ref() + .map(|a| serde_json::to_string(a).unwrap_or_default()) + .unwrap_or_else(|| "CENTER".to_string()), + ), + stroke_dash_array: origin + .stroke_dashes + .clone() + .map(|v| v.into_iter().map(|x| x as f32).collect()), + effect: Self::convert_effects(Some(&origin.effects)), + children, + opacity: Self::convert_opacity(origin.visible), + clip: origin.clips_content, + })) + } + + /// Convert Figma's blend mode to our BlendMode + fn convert_blend_mode(blend_mode: figma_api::models::BlendMode) -> BlendMode { + match blend_mode { + figma_api::models::BlendMode::Normal => BlendMode::Normal, + figma_api::models::BlendMode::Multiply => BlendMode::Multiply, + figma_api::models::BlendMode::Screen => BlendMode::Screen, + figma_api::models::BlendMode::Overlay => BlendMode::Overlay, + figma_api::models::BlendMode::Darken => BlendMode::Darken, + figma_api::models::BlendMode::Lighten => BlendMode::Lighten, + figma_api::models::BlendMode::ColorDodge => BlendMode::ColorDodge, + figma_api::models::BlendMode::ColorBurn => BlendMode::ColorBurn, + figma_api::models::BlendMode::HardLight => BlendMode::HardLight, + figma_api::models::BlendMode::SoftLight => BlendMode::SoftLight, + figma_api::models::BlendMode::Difference => BlendMode::Difference, + figma_api::models::BlendMode::Exclusion => BlendMode::Exclusion, + figma_api::models::BlendMode::Hue => BlendMode::Hue, + figma_api::models::BlendMode::Saturation => BlendMode::Saturation, + figma_api::models::BlendMode::Color => BlendMode::Color, + figma_api::models::BlendMode::Luminosity => BlendMode::Luminosity, + figma_api::models::BlendMode::PassThrough => BlendMode::Normal, + figma_api::models::BlendMode::LinearBurn => BlendMode::ColorBurn, + figma_api::models::BlendMode::LinearDodge => BlendMode::ColorDodge, + } + } + + fn convert_text(&mut self, origin: &Box) -> Result { + let style = origin.style.as_ref(); + + // Register the font family and postscript name if they exist + if let Some(font_family) = &style.font_family { + self.register_font( + font_family.clone(), + style.font_post_script_name.clone(), + style.font_style.clone(), + ); + } + + Ok(Node::TextSpan(TextSpanNode { + base: BaseNode { + id: origin.id.clone(), + name: origin.name.clone(), + active: origin.visible.unwrap_or(true), + }, + transform: Self::convert_transform(origin.relative_transform.as_ref()), + size: Size { + width: origin.size.as_ref().map_or(0.0, |size| size.x as f32), + height: origin.size.as_ref().map_or(0.0, |size| size.y as f32), + }, + text: origin.characters.clone(), + text_style: TextStyle { + text_decoration: Self::convert_text_decoration(style.text_decoration.as_ref()), + font_family: style + .font_family + .clone() + .unwrap_or_else(|| "Inter".to_string()), + font_size: style.font_size.unwrap_or(14.0) as f32, + font_weight: FontWeight::new(style.font_weight.unwrap_or(400.0) as u32), + letter_spacing: style.letter_spacing.map(|v| v as f32), + italic: style.italic.unwrap_or(false), + line_height: style.line_height_px.map(|v| v as f32), + text_transform: match origin.style.text_case.as_ref() { + Some(figma_api::models::type_style::TextCase::Upper) => { + TextTransform::Uppercase + } + Some(figma_api::models::type_style::TextCase::Lower) => { + TextTransform::Lowercase + } + Some(figma_api::models::type_style::TextCase::Title) => { + TextTransform::Capitalize + } + Some(figma_api::models::type_style::TextCase::SmallCaps) => TextTransform::None, + Some(figma_api::models::type_style::TextCase::SmallCapsForced) => { + TextTransform::None + } + None => TextTransform::None, + }, + }, + text_align: Self::convert_text_align(style.text_align_horizontal.as_ref()), + text_align_vertical: Self::convert_text_align_vertical( + style.text_align_vertical.as_ref(), + ), + fill: self.convert_fills(Some(&origin.fills)).unwrap_or(BLACK), + stroke: self.convert_strokes(Some(&origin.strokes)), + stroke_width: Some(origin.stroke_weight.unwrap_or(0.0) as f32), + stroke_align: StrokeAlign::Inside, + opacity: Self::convert_opacity(origin.visible), + blend_mode: Self::convert_blend_mode(origin.blend_mode), + })) + } + + fn convert_vector(&mut self, origin: &Box) -> Result { + let mut children = Vec::new(); + let mut path_index = 0; + + // Convert fill geometries to path nodes + if let Some(fill_geometries) = &origin.fill_geometry { + for geometry in fill_geometries { + let path_node = Node::Path(PathNode { + base: BaseNode { + id: format!("{}-path-{}", origin.id, path_index), + name: format!("{}-path-{}", origin.name, path_index), + active: origin.visible.unwrap_or(true), + }, + transform: AffineTransform::identity(), + fill: self + .convert_fills(Some(&origin.fills)) + .unwrap_or(TRANSPARENT), + data: geometry.path.clone(), + stroke: Paint::Solid(SolidPaint { + color: Color(0, 0, 0, 0), + opacity: 0.0, + }), + stroke_width: 0.0, + stroke_align: StrokeAlign::Inside, + stroke_dash_array: None, + opacity: Self::convert_opacity(origin.visible), + blend_mode: Self::convert_blend_mode(origin.blend_mode), + effect: Self::convert_effects(Some(&origin.effects)), + }); + children.push(self.repository.insert(path_node)); + path_index += 1; + } + } + + // Convert stroke geometries to path nodes + // stroke paint should be applied to the path, not stroke, as the stroke geometry is the baked path of the stroke. + if let Some(stroke_geometries) = &origin.stroke_geometry { + for geometry in stroke_geometries { + let path_node = Node::Path(PathNode { + base: BaseNode { + id: format!("{}-path-{}", origin.id, path_index), + name: format!("{}-path-{}", origin.name, path_index), + active: origin.visible.unwrap_or(true), + }, + transform: AffineTransform::identity(), + fill: self + .convert_strokes(Some(&origin.strokes)) + .unwrap_or(TRANSPARENT), + data: geometry.path.clone(), + stroke: TRANSPARENT, + stroke_width: 0.0, + stroke_align: StrokeAlign::Inside, + stroke_dash_array: None, + opacity: Self::convert_opacity(origin.visible), + blend_mode: Self::convert_blend_mode(origin.blend_mode), + effect: Self::convert_effects(Some(&origin.effects)), + }); + children.push(self.repository.insert(path_node)); + path_index += 1; + } + } + + // Create a group node containing all the path nodes + Ok(Node::Container(ContainerNode { + base: BaseNode { + id: origin.id.clone(), + name: origin.name.clone(), + active: origin.visible.unwrap_or(true), + }, + blend_mode: Self::convert_blend_mode(origin.blend_mode), + transform: Self::convert_transform(origin.relative_transform.as_ref()), + size: Self::convert_size(origin.size.as_ref()), + corner_radius: RectangularCornerRadius::zero(), + fill: TRANSPARENT, + stroke: None, + stroke_width: 0.0, + stroke_align: StrokeAlign::Inside, + stroke_dash_array: None, + effect: None, + children, + opacity: Self::convert_opacity(origin.visible), + clip: false, + })) + } + + fn convert_boolean_operation( + &mut self, + origin: &Box, + ) -> Result { + let children = origin + .children + .iter() + .map(|child| self.convert_sub_canvas_node(child)) + .collect::, _>>()?; + + let transform = Self::convert_transform(origin.relative_transform.as_ref()); + + let op = match origin.boolean_operation { + figma_api::models::boolean_operation_node::BooleanOperation::Union => { + BooleanPathOperation::Union + } + figma_api::models::boolean_operation_node::BooleanOperation::Intersect => { + BooleanPathOperation::Intersection + } + figma_api::models::boolean_operation_node::BooleanOperation::Subtract => { + BooleanPathOperation::Difference + } + figma_api::models::boolean_operation_node::BooleanOperation::Exclude => { + BooleanPathOperation::Xor + } + }; + + Ok(Node::BooleanOperation(BooleanPathOperationNode { + base: BaseNode { + id: origin.id.clone(), + name: origin.name.clone(), + active: origin.visible.unwrap_or(true), + }, + transform, + op: op, + children, + // corner_radius: RectangularCornerRadius::zero(), + fill: self + .convert_fills(Some(&origin.fills)) + .unwrap_or(TRANSPARENT), + stroke: self.convert_strokes(Some(&origin.strokes)), + stroke_width: origin.stroke_weight.unwrap_or(0.0) as f32, + stroke_align: Self::convert_stroke_align( + origin + .stroke_align + .as_ref() + .map(|a| serde_json::to_string(a).unwrap_or_default()) + .unwrap_or_else(|| "CENTER".to_string()), + ), + stroke_dash_array: origin + .stroke_dashes + .clone() + .map(|v| v.into_iter().map(|x| x as f32).collect()), + effect: Self::convert_effects(Some(&origin.effects)), + opacity: Self::convert_opacity(origin.visible), + blend_mode: Self::convert_blend_mode(origin.blend_mode), + })) + } + + fn convert_star(&mut self, origin: &Box) -> Result { + let size = Self::convert_size(origin.size.as_ref()); + let transform = Self::convert_transform(origin.relative_transform.as_ref()); + + Ok(Node::RegularStarPolygon(RegularStarPolygonNode { + base: BaseNode { + id: origin.id.clone(), + name: origin.name.clone(), + active: origin.visible.unwrap_or(true), + }, + transform, + size, + // not available in api? + point_count: 5, // Default to 5 points for a star + inner_radius: 0.4, // Default inner radius to 0.4 (40% of outer radius) + corner_radius: 0.0, // Figma stars don't have corner radius + fill: self + .convert_fills(Some(&origin.fills)) + .unwrap_or(TRANSPARENT), + stroke: self + .convert_strokes(Some(&origin.strokes)) + .unwrap_or(TRANSPARENT), + stroke_width: origin.stroke_weight.unwrap_or(1.0) as f32, + stroke_align: Self::convert_stroke_align( + origin + .stroke_align + .as_ref() + .map(|a| serde_json::to_string(a).unwrap_or_default()) + .unwrap_or_else(|| "CENTER".to_string()), + ), + stroke_dash_array: origin + .stroke_dashes + .clone() + .map(|v| v.into_iter().map(|x| x as f32).collect()), + opacity: Self::convert_opacity(origin.visible), + blend_mode: Self::convert_blend_mode(origin.blend_mode), + effect: Self::convert_effects(Some(&origin.effects)), + })) + } + + fn convert_line(&mut self, origin: &Box) -> Result { + let mut size = Self::convert_size(origin.size.as_ref()); + size.height = 0.0; // Lines have no height in our schema + let transform = Self::convert_transform(origin.relative_transform.as_ref()); + + Ok(Node::Line(LineNode { + base: BaseNode { + id: origin.id.clone(), + name: origin.name.clone(), + active: origin.visible.unwrap_or(true), + }, + transform, + size, + stroke: self + .convert_strokes(Some(&origin.strokes)) + .unwrap_or(TRANSPARENT), + stroke_width: origin.stroke_weight.unwrap_or(1.0) as f32, + stroke_align: Self::convert_stroke_align( + origin + .stroke_align + .as_ref() + .map(|a| serde_json::to_string(a).unwrap_or_default()) + .unwrap_or_else(|| "CENTER".to_string()), + ), + stroke_dash_array: origin + .stroke_dashes + .clone() + .map(|v| v.into_iter().map(|x| x as f32).collect()), + opacity: Self::convert_opacity(origin.visible), + blend_mode: Self::convert_blend_mode(origin.blend_mode), + })) + } + + fn convert_ellipse( + &mut self, + origin: &Box, + ) -> Result { + let size = Self::convert_size(origin.size.as_ref()); + let transform = + Self::convert_transform(origin.relative_transform.as_ref().map(|v| v.as_ref())); + + Ok(Node::Ellipse(EllipseNode { + base: BaseNode { + id: origin.id.clone(), + name: origin.name.clone(), + active: origin.visible.unwrap_or(true), + }, + transform, + size, + fill: self + .convert_fills(Some(&origin.fills)) + .unwrap_or(TRANSPARENT), + stroke: self + .convert_strokes(Some(&origin.strokes)) + .unwrap_or(TRANSPARENT), + stroke_width: origin.stroke_weight.unwrap_or(1.0) as f32, + stroke_align: Self::convert_stroke_align( + origin + .stroke_align + .as_ref() + .map(|a| serde_json::to_string(a).unwrap_or_default()) + .unwrap_or_else(|| "CENTER".to_string()), + ), + stroke_dash_array: origin + .stroke_dashes + .clone() + .map(|v| v.into_iter().map(|x| x as f32).collect()), + opacity: Self::convert_opacity(origin.visible), + blend_mode: Self::convert_blend_mode(origin.blend_mode), + effect: Self::convert_effects(Some(&origin.effects)), + })) + } + + fn convert_regular_polygon( + &mut self, + origin: &Box, + ) -> Result { + let size = Self::convert_size(origin.size.as_ref()); + let transform = Self::convert_transform(origin.relative_transform.as_ref()); + Ok(Node::RegularPolygon(RegularPolygonNode { + base: BaseNode { + id: origin.id.clone(), + name: origin.name.clone(), + active: origin.visible.unwrap_or(true), + }, + transform, + size, + // No count in api ? + point_count: 3, + corner_radius: origin.corner_radius.unwrap_or(0.0) as f32, + fill: self + .convert_fills(Some(&origin.fills)) + .unwrap_or(TRANSPARENT), + stroke: self + .convert_strokes(Some(&origin.strokes)) + .unwrap_or(TRANSPARENT), + stroke_width: origin.stroke_weight.unwrap_or(1.0) as f32, + stroke_align: Self::convert_stroke_align( + origin + .stroke_align + .as_ref() + .map(|a| serde_json::to_string(a).unwrap_or_default()) + .unwrap_or_else(|| "CENTER".to_string()), + ), + stroke_dash_array: origin + .stroke_dashes + .clone() + .map(|v| v.into_iter().map(|x| x as f32).collect()), + opacity: Self::convert_opacity(origin.visible), + blend_mode: Self::convert_blend_mode(origin.blend_mode), + effect: Self::convert_effects(Some(&origin.effects)), + })) + } + + fn convert_rectangle(&mut self, origin: &Box) -> Result { + let size = Self::convert_size(origin.size.as_ref()); + let transform = Self::convert_transform(origin.relative_transform.as_ref()); + + Ok(Node::Rectangle(RectangleNode { + base: BaseNode { + id: origin.id.clone(), + name: origin.name.clone(), + active: origin.visible.unwrap_or(true), + }, + transform, + size, + corner_radius: Self::convert_corner_radius( + origin.corner_radius, + origin.rectangle_corner_radii.as_ref(), + ), + fill: self + .convert_fills(Some(&origin.fills)) + .unwrap_or(TRANSPARENT), + stroke: self + .convert_strokes(Some(&origin.strokes)) + .unwrap_or(TRANSPARENT), + stroke_width: origin.stroke_weight.unwrap_or(1.0) as f32, + stroke_align: Self::convert_stroke_align( + origin + .stroke_align + .as_ref() + .map(|a| serde_json::to_string(a).unwrap_or_default()) + .unwrap_or_else(|| "CENTER".to_string()), + ), + stroke_dash_array: origin + .stroke_dashes + .clone() + .map(|v| v.into_iter().map(|x| x as f32).collect()), + opacity: Self::convert_opacity(origin.visible), + blend_mode: Self::convert_blend_mode(origin.blend_mode), + effect: Self::convert_effects(Some(&origin.effects)), + })) + } + + fn convert_group(&mut self, origin: &Box) -> Result { + let children = origin + .children + .iter() + .map(|child| self.convert_sub_canvas_node(child)) + .collect::, _>>()?; + + let size = Self::convert_size(origin.size.as_ref()); + let transform = Self::convert_transform(origin.relative_transform.as_ref()); + + Ok(Node::Container(ContainerNode { + base: BaseNode { + id: origin.id.clone(), + name: origin.name.clone(), + active: origin.visible.unwrap_or(true), + }, + blend_mode: Self::convert_blend_mode(origin.blend_mode), + transform, + size, + corner_radius: Self::convert_corner_radius( + origin.corner_radius, + origin.rectangle_corner_radii.as_ref(), + ), + fill: self.convert_fills(None).unwrap_or(TRANSPARENT), + stroke: None, + stroke_width: 0.0, + stroke_align: StrokeAlign::Inside, + stroke_dash_array: None, + effect: None, + children, + opacity: 1.0, + clip: origin.clips_content, + })) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_convert_node() { + // TODO: Add tests + } +} diff --git a/crates/grida-canvas/src/io/io_json.rs b/crates/grida-canvas/src/io/io_json.rs new file mode 100644 index 0000000000..0221465f53 --- /dev/null +++ b/crates/grida-canvas/src/io/io_json.rs @@ -0,0 +1,504 @@ +use crate::node::schema::*; +use math2::transform::AffineTransform; +use serde::Deserialize; +use serde_json::Value; +use std::collections::HashMap; + +#[derive(Debug, Deserialize)] +pub struct IOCanvasFile { + pub version: String, + pub document: IODocument, +} + +#[derive(Debug, Deserialize)] +pub struct IODocument { + pub bitmaps: HashMap, + pub properties: HashMap, + pub nodes: HashMap, + pub scenes: HashMap, + pub entry_scene_id: Option, +} + +#[derive(Debug, Deserialize)] +pub struct IOScene { + pub id: String, + pub name: String, + #[serde(rename = "type")] + pub type_name: String, + pub children: Vec, + #[serde(rename = "backgroundColor")] + pub background_color: Option, + pub guides: Option>, + pub constraints: Option>, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "type")] +pub enum IONode { + #[serde(rename = "container")] + Container(IOContainerNode), + #[serde(rename = "text")] + Text(IOTextNode), + #[serde(rename = "vector")] + Vector(IOVectorNode), + #[serde(rename = "ellipse")] + Ellipse(IOEllipseNode), + #[serde(other)] + Unknown, +} + +#[derive(Debug, Deserialize)] +pub struct IOContainerNode { + pub id: String, + pub name: String, + #[serde(default = "default_active")] + pub active: bool, + #[serde(default = "default_locked")] + pub locked: bool, + #[serde(default = "default_opacity")] + pub opacity: f32, + #[serde(default = "default_rotation")] + pub rotation: f32, + #[serde(rename = "zIndex", default = "default_z_index")] + pub z_index: i32, + pub position: Option, + pub left: f32, + pub top: f32, + pub width: serde_json::Value, + pub height: serde_json::Value, + pub children: Vec, + pub expanded: Option, + pub fill: Option, + pub border: Option, + pub style: Option>, + #[serde( + rename = "cornerRadius", + deserialize_with = "deserialize_corner_radius" + )] + pub corner_radius: Option, + pub padding: Option, + pub layout: Option, + pub direction: Option, + #[serde(rename = "mainAxisAlignment")] + pub main_axis_alignment: Option, + #[serde(rename = "crossAxisAlignment")] + pub cross_axis_alignment: Option, + #[serde(rename = "mainAxisGap")] + pub main_axis_gap: Option, + #[serde(rename = "crossAxisGap")] + pub cross_axis_gap: Option, +} + +fn deserialize_corner_radius<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + + match value { + None => Ok(None), + Some(v) => match v { + serde_json::Value::Number(n) => { + let radius = n.as_f64().unwrap_or(0.0) as f32; + Ok(Some(RectangularCornerRadius::all(radius))) + } + serde_json::Value::Array(arr) => { + if arr.len() == 4 { + let values: Vec = arr + .into_iter() + .map(|v| v.as_f64().unwrap_or(0.0) as f32) + .collect(); + Ok(Some(RectangularCornerRadius { + tl: values[0], + tr: values[1], + bl: values[2], + br: values[3], + })) + } else { + Ok(None) + } + } + _ => Ok(None), + }, + } +} + +#[derive(Debug, Deserialize)] +pub struct IOTextNode { + pub id: String, + pub name: String, + #[serde(default = "default_active")] + pub active: bool, + #[serde(default = "default_locked")] + pub locked: bool, + #[serde(default = "default_opacity")] + pub opacity: f32, + #[serde(default = "default_rotation")] + pub rotation: f32, + #[serde(rename = "zIndex", default = "default_z_index")] + pub z_index: i32, + pub position: Option, + pub left: f32, + pub top: f32, + pub right: Option, + pub bottom: Option, + pub width: serde_json::Value, + pub height: serde_json::Value, + pub fill: Option, + pub style: Option>, + pub text: String, + #[serde(rename = "textAlign", default = "default_text_align")] + pub text_align: TextAlign, + #[serde(rename = "textAlignVertical", default = "default_text_align_vertical")] + pub text_align_vertical: TextAlignVertical, + #[serde(rename = "textDecoration", default = "default_text_decoration")] + pub text_decoration: TextDecoration, + #[serde(rename = "lineHeight")] + pub line_height: Option, + #[serde(rename = "letterSpacing")] + pub letter_spacing: Option, + #[serde(rename = "fontSize")] + pub font_size: Option, + #[serde(rename = "fontFamily")] + pub font_family: Option, + #[serde(rename = "fontWeight", default = "default_font_weight")] + pub font_weight: FontWeight, +} + +#[derive(Debug, Deserialize)] +pub struct IOVectorNode { + pub id: String, + pub name: String, + #[serde(default = "default_active")] + pub active: bool, + #[serde(default = "default_locked")] + pub locked: bool, + #[serde(default = "default_opacity")] + pub opacity: f32, + #[serde(default = "default_rotation")] + pub rotation: f32, + #[serde(rename = "zIndex", default = "default_z_index")] + pub z_index: i32, + pub position: Option, + pub left: f32, + pub top: f32, + pub width: f32, + pub height: f32, + pub fill: Option, + pub paths: Option>, +} + +#[derive(Debug, Deserialize)] +pub struct IOEllipseNode { + pub id: String, + pub name: String, + #[serde(default = "default_active")] + pub active: bool, + #[serde(default = "default_locked")] + pub locked: bool, + #[serde(default = "default_opacity")] + pub opacity: f32, + #[serde(default = "default_rotation")] + pub rotation: f32, + #[serde(rename = "zIndex", default = "default_z_index")] + pub z_index: i32, + pub position: Option, + pub left: f32, + pub top: f32, + pub width: f32, + pub height: f32, + pub fill: Option, + #[serde(rename = "strokeWidth")] + pub stroke_width: Option, + #[serde(rename = "strokeCap")] + pub stroke_cap: Option, + pub effects: Option>, +} + +#[derive(Debug, Deserialize)] +pub struct Fill { + #[serde(rename = "type")] + pub kind: String, + pub color: Option, +} + +#[derive(Debug, Deserialize)] +pub struct Border { + #[serde(rename = "borderWidth")] + pub border_width: Option, + #[serde(rename = "borderColor")] + pub border_color: Option, + #[serde(rename = "borderStyle")] + pub border_style: Option, +} + +#[derive(Debug, Deserialize)] +pub struct IOPath { + pub d: String, + #[serde(rename = "fillRule")] + pub fill_rule: String, + pub fill: String, +} + +#[derive(Debug, Deserialize)] +pub struct RGBA { + pub r: u8, + pub g: u8, + pub b: u8, + pub a: f32, +} + +// Default value functions +fn default_active() -> bool { + true +} +fn default_locked() -> bool { + false +} +fn default_opacity() -> f32 { + 1.0 +} +fn default_rotation() -> f32 { + 0.0 +} +fn default_z_index() -> i32 { + 0 +} +fn default_text_align() -> TextAlign { + TextAlign::Left +} +fn default_text_align_vertical() -> TextAlignVertical { + TextAlignVertical::Top +} +fn default_text_decoration() -> TextDecoration { + TextDecoration::None +} +fn default_font_weight() -> FontWeight { + FontWeight::new(400) +} + +pub fn parse(file: &str) -> Result { + serde_json::from_str(file) +} + +impl From for Color { + fn from(color: RGBA) -> Self { + Color(color.r, color.g, color.b, (color.a * 255.0) as u8) + } +} + +impl From> for Paint { + fn from(fill: Option) -> Self { + match fill { + Some(fill) => match fill.kind.as_str() { + "solid" => { + if let Some(color) = fill.color { + Paint::Solid(SolidPaint { + color: Color(color.r, color.g, color.b, (color.a * 255.0) as u8), + opacity: 1.0, + }) + } else { + Paint::Solid(SolidPaint { + color: Color(0, 0, 0, 0), + opacity: 1.0, + }) + } + } + _ => Paint::Solid(SolidPaint { + color: Color(0, 0, 0, 0), + opacity: 1.0, + }), + }, + None => Paint::Solid(SolidPaint { + color: Color(0, 0, 0, 0), + opacity: 1.0, + }), + } + } +} + +impl From for ContainerNode { + fn from(node: IOContainerNode) -> Self { + let width = match node.width { + Value::Number(n) => n.as_f64().unwrap_or(0.0) as f32, + _ => 0.0, + }; + let height = match node.height { + Value::Number(n) => n.as_f64().unwrap_or(0.0) as f32, + _ => 0.0, + }; + ContainerNode { + base: BaseNode { + id: node.id, + name: node.name, + active: node.active, + }, + blend_mode: BlendMode::Normal, + transform: AffineTransform::new(node.left, node.top, node.rotation), + size: Size { width, height }, + corner_radius: node + .corner_radius + .unwrap_or(RectangularCornerRadius::zero()), + fill: node.fill.into(), + stroke: None, + stroke_width: 0.0, + stroke_align: StrokeAlign::Inside, + stroke_dash_array: None, + effect: None, + children: node.children, + opacity: node.opacity, + clip: true, + } + } +} + +impl From for TextSpanNode { + fn from(node: IOTextNode) -> Self { + let width = match node.width { + Value::Number(n) => n.as_f64().unwrap_or(0.0) as f32, + _ => 0.0, + }; + let height = match node.height { + Value::Number(n) => n.as_f64().unwrap_or(0.0) as f32, + _ => 0.0, + }; + TextSpanNode { + base: BaseNode { + id: node.id, + name: node.name, + active: node.active, + }, + blend_mode: BlendMode::Normal, + transform: AffineTransform::new(node.left, node.top, node.rotation), + size: Size { width, height }, + text: node.text, + text_style: TextStyle { + text_decoration: node.text_decoration, + font_family: node.font_family.unwrap_or_else(|| "Inter".to_string()), + font_size: node.font_size.unwrap_or(14.0), + font_weight: node.font_weight, + italic: false, + letter_spacing: node.letter_spacing, + line_height: node.line_height, + text_transform: TextTransform::None, + }, + text_align: node.text_align, + text_align_vertical: node.text_align_vertical, + fill: node.fill.into(), + stroke: None, + stroke_width: None, + stroke_align: StrokeAlign::Inside, + opacity: node.opacity, + } + } +} + +impl From for Node { + fn from(node: IOEllipseNode) -> Self { + let transform = AffineTransform::new(node.left, node.top, node.rotation); + + Node::Ellipse(EllipseNode { + base: BaseNode { + id: node.id, + name: node.name, + active: node.active, + }, + blend_mode: BlendMode::Normal, + transform, + size: Size { + width: node.width, + height: node.height, + }, + fill: node.fill.into(), + stroke: Paint::Solid(SolidPaint { + color: Color(0, 0, 0, 255), + opacity: 1.0, + }), + stroke_width: node.stroke_width.unwrap_or(0.0), + stroke_align: StrokeAlign::Inside, + stroke_dash_array: None, + effect: None, + opacity: node.opacity, + }) + } +} + +impl From for Node { + fn from(node: IOVectorNode) -> Self { + let transform = AffineTransform::new(node.left, node.top, node.rotation); + + // For vector nodes, we'll create a path node with the path data + Node::Path(PathNode { + base: BaseNode { + id: node.id, + name: node.name, + active: node.active, + }, + blend_mode: BlendMode::Normal, + transform, + fill: node.fill.into(), + data: node.paths.map_or("".to_string(), |paths| { + paths + .iter() + .map(|path| path.d.clone()) + .collect::>() + .join(" ") + }), + stroke: Paint::Solid(SolidPaint { + color: Color(0, 0, 0, 255), + opacity: 1.0, + }), + stroke_width: 0.0, + stroke_align: StrokeAlign::Inside, + stroke_dash_array: None, + opacity: node.opacity, + effect: None, + }) + } +} + +impl From for Node { + fn from(node: IONode) -> Self { + match node { + IONode::Container(container) => Node::Container(container.into()), + IONode::Text(text) => Node::TextSpan(text.into()), + IONode::Vector(vector) => vector.into(), + IONode::Ellipse(ellipse) => ellipse.into(), + IONode::Unknown => Node::Group(GroupNode { + base: BaseNode { + id: "unknown".to_string(), + name: "Unknown Node".to_string(), + active: false, + }, + transform: AffineTransform::identity(), + children: vec![], + opacity: 0.0, + blend_mode: BlendMode::Normal, + }), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn parse_canvas_json() { + let path = "resources/local/document.json"; + let Ok(data) = fs::read_to_string(path) else { + eprintln!("test resource not found: {}", path); + return; + }; + let parsed: IOCanvasFile = serde_json::from_str(&data).expect("failed to parse JSON"); + + assert_eq!(parsed.version, "0.0.1-beta.1+20250303"); + assert!( + !parsed.document.nodes.is_empty(), + "nodes should not be empty" + ); + } +} diff --git a/crates/grida-canvas/src/io/mod.rs b/crates/grida-canvas/src/io/mod.rs new file mode 100644 index 0000000000..e02afa516f --- /dev/null +++ b/crates/grida-canvas/src/io/mod.rs @@ -0,0 +1,2 @@ +pub mod io_figma; +pub mod io_json; diff --git a/crates/grida-canvas/src/lib.rs b/crates/grida-canvas/src/lib.rs new file mode 100644 index 0000000000..b6c7f678b8 --- /dev/null +++ b/crates/grida-canvas/src/lib.rs @@ -0,0 +1,16 @@ +pub mod cache; +pub mod font_loader; +pub mod hit_test; +pub mod image_loader; +pub mod io; +pub mod mipmap; +pub mod node; +pub mod painter; +pub mod repository; +pub mod resource_loader; +pub mod runtime; +pub mod text; +pub mod webfont_helper; + +#[cfg(not(target_arch = "wasm32"))] +pub mod window; diff --git a/crates/grida-canvas/src/mipmap.rs b/crates/grida-canvas/src/mipmap.rs new file mode 100644 index 0000000000..ba6903d052 --- /dev/null +++ b/crates/grida-canvas/src/mipmap.rs @@ -0,0 +1,137 @@ +use skia_safe::{Image, Paint as SkPaint, Rect, surfaces}; + +/// Strategy for generating the scale levels for mipmaps. +#[derive(Debug, Clone)] +pub enum MipmapLevels { + /// Use the provided fixed scale steps. + Fixed(Vec), + /// Generate a power-of-two chain down to 1x1 for each image. + FullChain, +} + +/// Configuration for generating mipmaps for images. +#[derive(Debug, Clone)] +pub struct MipmapConfig { + pub levels: MipmapLevels, + /// Whether to progressively resize from the previously generated level. + /// This is usually faster for long chains. + pub chained: bool, +} + +impl Default for MipmapConfig { + fn default() -> Self { + Self { + levels: MipmapLevels::FullChain, + chained: true, + } + } +} + +#[derive(Debug, Clone)] +pub struct ImageMipmaps { + levels: Vec<(f32, Image)>, +} + +impl ImageMipmaps { + pub fn from_image(image: Image, config: &MipmapConfig) -> Self { + let mut levels = Vec::new(); + + let mut scales: Vec = match &config.levels { + MipmapLevels::Fixed(steps) => steps.clone(), + MipmapLevels::FullChain => { + let max_dim = image.width().max(image.height()).max(1) as f32; + let levels = max_dim.log2().ceil() as u32 + 1; + (0..levels).map(|i| 1.0 / 2f32.powi(i as i32)).collect() + } + }; + + if !scales.is_empty() { + // ensure the scales are sorted from large to small for efficient chaining + scales.sort_by(|a, b| b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal)); + + if config.chained { + // start from the original image or the first scale + let mut prev_scale = 1.0; + let mut prev_img = image.clone(); + + for &scale in &scales { + if (scale - 1.0).abs() > f32::EPSILON { + let ratio = scale / prev_scale; + prev_img = scale_image(&prev_img, ratio); + prev_scale = scale; + } + + levels.push((scale, prev_img.clone())); + } + } else { + for &scale in &scales { + let img = if (scale - 1.0).abs() < f32::EPSILON { + image.clone() + } else { + scale_image(&image, scale) + }; + levels.push((scale, img)); + } + } + } + Self { levels } + } + + /// Number of mipmap levels. + pub fn level_count(&self) -> usize { + self.levels.len() + } + + /// Returns the image for the smallest mip level. + pub fn last_level_image(&self) -> Option<&Image> { + self.levels.last().map(|(_, img)| img) + } + + pub fn best_for_zoom(&self, zoom: f32) -> Option<&Image> { + if self.levels.is_empty() { + return None; + } + + for (scale, image) in self.levels.iter().rev() { + if zoom <= *scale { + return Some(image); + } + } + Some(&self.levels[0].1) + } + + pub fn best_for_size(&self, width: f32, height: f32) -> Option<&Image> { + if self.levels.is_empty() { + return None; + } + + let base_width = self.levels[0].1.width() as f32; + let base_height = self.levels[0].1.height() as f32; + let required_scale = (width / base_width).max(height / base_height); + + for (scale, image) in self.levels.iter().rev() { + if required_scale <= *scale { + return Some(image); + } + } + + Some(&self.levels[0].1) + } +} + +fn scale_image(image: &Image, scale: f32) -> Image { + let width = ((image.width() as f32 * scale).round() as i32).max(1); + let height = ((image.height() as f32 * scale).round() as i32).max(1); + let Some(mut surface) = surfaces::raster_n32_premul((width, height)) else { + return image.clone(); + }; + let canvas = surface.canvas(); + let paint = SkPaint::default(); + canvas.draw_image_rect( + image, + None, + Rect::from_xywh(0.0, 0.0, width as f32, height as f32), + &paint, + ); + surface.image_snapshot() +} diff --git a/crates/grida-canvas/src/node/factory.rs b/crates/grida-canvas/src/node/factory.rs new file mode 100644 index 0000000000..6c0083c7d1 --- /dev/null +++ b/crates/grida-canvas/src/node/factory.rs @@ -0,0 +1,251 @@ +use super::schema::*; +use math2::transform::AffineTransform; +use uuid::Uuid; + +/// Factory for creating nodes with default values +pub struct NodeFactory; + +impl NodeFactory { + pub fn new() -> Self { + Self {} + } + + fn id(&self) -> String { + // random id + let id = Uuid::new_v4(); + id.to_string() + } + + // Internal factory defaults + const DEFAULT_SIZE: Size = Size { + width: 100.0, + height: 100.0, + }; + + const DEFAULT_COLOR: Color = Color(255, 255, 255, 255); + const DEFAULT_STROKE_COLOR: Color = Color(0, 0, 0, 255); + const DEFAULT_STROKE_WIDTH: f32 = 1.0; + const DEFAULT_STROKE_ALIGN: StrokeAlign = StrokeAlign::Inside; + const DEFAULT_OPACITY: f32 = 1.0; + + fn default_base_node(&self) -> BaseNode { + BaseNode { + id: self.id(), + name: String::new(), + active: true, + } + } + + fn default_solid_paint(color: Color) -> Paint { + Paint::Solid(SolidPaint { + color, + opacity: 1.0, + }) + } + + /// Creates a new rectangle node with default values + pub fn create_rectangle_node(&self) -> RectangleNode { + RectangleNode { + base: self.default_base_node(), + transform: AffineTransform::identity(), + size: Self::DEFAULT_SIZE, + corner_radius: RectangularCornerRadius::zero(), + fill: Self::default_solid_paint(Self::DEFAULT_COLOR), + stroke: Self::default_solid_paint(Self::DEFAULT_STROKE_COLOR), + stroke_width: Self::DEFAULT_STROKE_WIDTH, + stroke_align: Self::DEFAULT_STROKE_ALIGN, + stroke_dash_array: None, + opacity: Self::DEFAULT_OPACITY, + blend_mode: BlendMode::Normal, + effect: None, + } + } + + /// Creates a new ellipse node with default values + pub fn create_ellipse_node(&self) -> EllipseNode { + EllipseNode { + base: self.default_base_node(), + transform: AffineTransform::identity(), + size: Self::DEFAULT_SIZE, + fill: Self::default_solid_paint(Self::DEFAULT_COLOR), + stroke: Self::default_solid_paint(Self::DEFAULT_STROKE_COLOR), + stroke_width: Self::DEFAULT_STROKE_WIDTH, + stroke_align: Self::DEFAULT_STROKE_ALIGN, + stroke_dash_array: None, + opacity: Self::DEFAULT_OPACITY, + blend_mode: BlendMode::Normal, + effect: None, + } + } + + /// Creates a new line node with default values + pub fn create_line_node(&self) -> LineNode { + LineNode { + base: self.default_base_node(), + transform: AffineTransform::identity(), + size: Size { + width: Self::DEFAULT_SIZE.width, + height: 0.0, + }, + stroke: Self::default_solid_paint(Self::DEFAULT_STROKE_COLOR), + stroke_width: Self::DEFAULT_STROKE_WIDTH, + stroke_align: Self::DEFAULT_STROKE_ALIGN, + stroke_dash_array: None, + opacity: Self::DEFAULT_OPACITY, + blend_mode: BlendMode::Normal, + } + } + + /// Creates a new text span node with default values + pub fn create_text_span_node(&self) -> TextSpanNode { + TextSpanNode { + base: self.default_base_node(), + transform: AffineTransform::identity(), + size: Size { + width: Self::DEFAULT_SIZE.width, + height: 20.0, + }, + text: String::new(), + text_style: TextStyle { + text_decoration: TextDecoration::None, + font_family: String::from("Arial"), + font_size: 16.0, + font_weight: FontWeight::default(), + italic: false, + letter_spacing: None, + line_height: None, + text_transform: TextTransform::None, + }, + text_align: TextAlign::Left, + text_align_vertical: TextAlignVertical::Top, + fill: Self::default_solid_paint(Self::DEFAULT_STROKE_COLOR), + stroke: None, + stroke_width: None, + stroke_align: Self::DEFAULT_STROKE_ALIGN, + opacity: Self::DEFAULT_OPACITY, + blend_mode: BlendMode::Normal, + } + } + + /// Creates a new group node with default values + pub fn create_group_node(&self) -> GroupNode { + GroupNode { + base: self.default_base_node(), + transform: AffineTransform::identity(), + children: Vec::new(), + opacity: Self::DEFAULT_OPACITY, + blend_mode: BlendMode::Normal, + } + } + + /// Creates a new container node with default values + pub fn create_container_node(&self) -> ContainerNode { + ContainerNode { + base: self.default_base_node(), + transform: AffineTransform::identity(), + size: Self::DEFAULT_SIZE, + corner_radius: RectangularCornerRadius::zero(), + children: Vec::new(), + fill: Self::default_solid_paint(Self::DEFAULT_COLOR), + stroke: None, + stroke_width: Self::DEFAULT_STROKE_WIDTH, + stroke_align: Self::DEFAULT_STROKE_ALIGN, + stroke_dash_array: None, + opacity: Self::DEFAULT_OPACITY, + blend_mode: BlendMode::Normal, + effect: None, + clip: true, + } + } + + /// Creates a new path node with default values + pub fn create_path_node(&self) -> PathNode { + PathNode { + base: self.default_base_node(), + transform: AffineTransform::identity(), + fill: Self::default_solid_paint(Self::DEFAULT_COLOR), + data: String::new(), + stroke: Self::default_solid_paint(Self::DEFAULT_STROKE_COLOR), + stroke_width: Self::DEFAULT_STROKE_WIDTH, + stroke_align: Self::DEFAULT_STROKE_ALIGN, + stroke_dash_array: None, + opacity: Self::DEFAULT_OPACITY, + blend_mode: BlendMode::Normal, + effect: None, + } + } + + /// Creates a new regular polygon node with default values + pub fn create_regular_polygon_node(&self) -> RegularPolygonNode { + RegularPolygonNode { + base: self.default_base_node(), + transform: AffineTransform::identity(), + size: Self::DEFAULT_SIZE, + point_count: 3, // Triangle by default + corner_radius: 0.0, + fill: Self::default_solid_paint(Self::DEFAULT_COLOR), + stroke: Self::default_solid_paint(Self::DEFAULT_STROKE_COLOR), + stroke_width: Self::DEFAULT_STROKE_WIDTH, + stroke_align: Self::DEFAULT_STROKE_ALIGN, + stroke_dash_array: None, + opacity: Self::DEFAULT_OPACITY, + blend_mode: BlendMode::Normal, + effect: None, + } + } + + pub fn create_regular_star_polygon_node(&self) -> RegularStarPolygonNode { + RegularStarPolygonNode { + base: self.default_base_node(), + transform: AffineTransform::identity(), + size: Self::DEFAULT_SIZE, + point_count: 5, // 5-pointed star by default + inner_radius: 0.4, // Default inner radius + corner_radius: 0.0, + fill: Self::default_solid_paint(Self::DEFAULT_COLOR), + stroke: Self::default_solid_paint(Self::DEFAULT_STROKE_COLOR), + stroke_width: Self::DEFAULT_STROKE_WIDTH, + stroke_align: Self::DEFAULT_STROKE_ALIGN, + stroke_dash_array: None, + opacity: Self::DEFAULT_OPACITY, + blend_mode: BlendMode::Normal, + effect: None, + } + } + + pub fn create_polygon_node(&self) -> PolygonNode { + PolygonNode { + base: self.default_base_node(), + transform: AffineTransform::identity(), + points: Vec::new(), + corner_radius: 0.0, + fill: Self::default_solid_paint(Self::DEFAULT_COLOR), + stroke: Self::default_solid_paint(Self::DEFAULT_STROKE_COLOR), + stroke_width: Self::DEFAULT_STROKE_WIDTH, + stroke_align: Self::DEFAULT_STROKE_ALIGN, + stroke_dash_array: None, + opacity: Self::DEFAULT_OPACITY, + blend_mode: BlendMode::Normal, + effect: None, + } + } + + /// Creates a new image node with default values + pub fn create_image_node(&self) -> ImageNode { + ImageNode { + base: self.default_base_node(), + transform: AffineTransform::identity(), + size: Self::DEFAULT_SIZE, + corner_radius: RectangularCornerRadius::zero(), + fill: Self::default_solid_paint(Self::DEFAULT_COLOR), + stroke: Self::default_solid_paint(Self::DEFAULT_STROKE_COLOR), + stroke_width: Self::DEFAULT_STROKE_WIDTH, + stroke_align: Self::DEFAULT_STROKE_ALIGN, + stroke_dash_array: None, + opacity: Self::DEFAULT_OPACITY, + blend_mode: BlendMode::Normal, + effect: None, + _ref: String::new(), + } + } +} diff --git a/crates/grida-canvas/src/node/geometry.rs b/crates/grida-canvas/src/node/geometry.rs new file mode 100644 index 0000000000..e69de29bb2 diff --git a/crates/grida-canvas/src/node/mod.rs b/crates/grida-canvas/src/node/mod.rs new file mode 100644 index 0000000000..7cdc81ad06 --- /dev/null +++ b/crates/grida-canvas/src/node/mod.rs @@ -0,0 +1,3 @@ +pub mod factory; +pub mod repository; +pub mod schema; diff --git a/crates/grida-canvas/src/node/repository.rs b/crates/grida-canvas/src/node/repository.rs new file mode 100644 index 0000000000..32c1d61ead --- /dev/null +++ b/crates/grida-canvas/src/node/repository.rs @@ -0,0 +1,129 @@ +use crate::node::schema::{Node, NodeId}; +use std::collections::HashMap; + +/// A repository for managing nodes with automatic ID indexing. +#[derive(Debug, Clone)] +pub struct NodeRepository { + /// The map of all nodes indexed by their IDs + nodes: HashMap, +} + +impl NodeRepository { + /// Creates a new empty node repository + pub fn new() -> Self { + Self { + nodes: HashMap::new(), + } + } + + /// Inserts a node into the repository, automatically indexing it by its ID. + /// Returns the node's ID. + pub fn insert(&mut self, node: Node) -> NodeId { + let id = match &node { + Node::Error(n) => n.base.id.clone(), + Node::Group(n) => n.base.id.clone(), + Node::Container(n) => n.base.id.clone(), + Node::Rectangle(n) => n.base.id.clone(), + Node::Ellipse(n) => n.base.id.clone(), + Node::Polygon(n) => n.base.id.clone(), + Node::RegularPolygon(n) => n.base.id.clone(), + Node::RegularStarPolygon(n) => n.base.id.clone(), + Node::Line(n) => n.base.id.clone(), + Node::TextSpan(n) => n.base.id.clone(), + Node::Path(n) => n.base.id.clone(), + Node::BooleanOperation(n) => n.base.id.clone(), + Node::Image(n) => n.base.id.clone(), + }; + self.nodes.insert(id.clone(), node); + id + } + + /// Gets a reference to a node by its ID + pub fn get(&self, id: &NodeId) -> Option<&Node> { + self.nodes.get(id) + } + + /// Gets a mutable reference to a node by its ID + pub fn get_mut(&mut self, id: &NodeId) -> Option<&mut Node> { + self.nodes.get_mut(id) + } + + /// Removes a node from the repository by its ID + pub fn remove(&mut self, id: &NodeId) -> Option { + self.nodes.remove(id) + } + + /// Returns an iterator over all nodes in the repository + pub fn iter(&self) -> impl Iterator { + self.nodes.iter() + } + + /// Returns the number of nodes in the repository + pub fn len(&self) -> usize { + self.nodes.len() + } + + /// Returns true if the repository is empty + pub fn is_empty(&self) -> bool { + self.nodes.is_empty() + } + + pub fn filter(&self, filter: impl Fn(&Node) -> bool) -> Self { + NodeRepository { + nodes: self + .nodes + .iter() + .filter(|(_, node)| filter(node)) + .map(|(k, v)| (k.clone(), v.clone())) + .collect(), + } + } +} + +impl Default for NodeRepository { + fn default() -> Self { + Self::new() + } +} + +impl FromIterator<(NodeId, Node)> for NodeRepository { + fn from_iter>(iter: T) -> Self { + let mut repo = Self::new(); + for (_, node) in iter { + repo.insert(node); + } + repo + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::node::schema::{BaseNode, ErrorNode, Size}; + + #[test] + fn node_repository_basic() { + let mut repo = NodeRepository::new(); + let node = Node::Error(ErrorNode { + base: BaseNode { + id: "1".to_string(), + name: "err".to_string(), + active: true, + }, + transform: math2::transform::AffineTransform::identity(), + size: Size { + width: 10.0, + height: 10.0, + }, + error: "err".to_string(), + opacity: 1.0, + }); + + let id = repo.insert(node.clone()); + assert!(repo.get(&id).is_some()); + assert_eq!(repo.len(), 1); + assert!(!repo.is_empty()); + repo.remove(&id); + assert!(repo.is_empty()); + } +} diff --git a/crates/grida-canvas/src/node/schema.rs b/crates/grida-canvas/src/node/schema.rs new file mode 100644 index 0000000000..b5d3b8849e --- /dev/null +++ b/crates/grida-canvas/src/node/schema.rs @@ -0,0 +1,1014 @@ +use crate::node::repository::NodeRepository; +use crate::painter::cvt; +use core::str; +use math2::box_fit::BoxFit; +use math2::rect::Rectangle; +use math2::transform::AffineTransform; +use serde::Deserialize; + +pub type NodeId = String; + +/// A 2D point with x and y coordinates. +#[derive(Debug, Clone, Copy)] +pub struct Point { + pub x: f32, + pub y: f32, +} + +impl Point { + /// Subtracts a scaled vector from this point. + /// + /// # Arguments + /// + /// * `other` - The point to subtract + /// * `scale` - The scale factor to apply to the other point + /// + /// # Returns + /// + /// A new point representing the result of the vector operation + pub fn subtract_scaled(&self, other: Point, scale: f32) -> Point { + Point { + x: self.x - other.x * scale, + y: self.y - other.y * scale, + } + } +} + +/// Boolean path operation. +#[derive(Debug, Clone, Copy)] +pub enum BooleanPathOperation { + Union, // A ∪ B + Intersection, // A ∩ B + Difference, // A - B + Xor, // A ⊕ B +} + +impl From for skia_safe::PathOp { + fn from(op: BooleanPathOperation) -> Self { + match op { + BooleanPathOperation::Union => skia_safe::PathOp::Union, + BooleanPathOperation::Intersection => skia_safe::PathOp::Intersect, + BooleanPathOperation::Difference => skia_safe::PathOp::Difference, + BooleanPathOperation::Xor => skia_safe::PathOp::XOR, + } + } +} + +/// Stroke alignment. +/// +/// - [Flutter](https://api.flutter.dev/flutter/painting/BorderSide/strokeAlign.html) +/// - [Figma](https://www.figma.com/plugin-docs/api/properties/nodes-strokealign/) +#[derive(Debug, Clone, Copy)] +pub enum StrokeAlign { + Inside, + Center, + Outside, +} + +#[derive(Debug, Clone, Copy)] +pub struct Color(pub u8, pub u8, pub u8, pub u8); + +/// Represents filter effects inspired by SVG `` primitives. +/// +/// See also: +/// - https://developer.mozilla.org/en-US/docs/Web/SVG/Element/feDropShadow +/// - https://developer.mozilla.org/en-US/docs/Web/SVG/Element/feGaussianBlur +#[derive(Debug, Clone)] +pub enum FilterEffect { + /// Drop shadow filter: offset + blur + color + DropShadow(FeDropShadow), + + /// Gaussian blur filter: blur only + GaussianBlur(FeGaussianBlur), + + /// Background blur filter: blur only + BackdropBlur(FeBackdropBlur), +} + +/// A background blur effect, similar to CSS `backdrop-filter: blur(...)` +#[derive(Debug, Clone, Copy)] +pub struct FeBackdropBlur { + /// Blur radius in logical pixels. + pub radius: f32, +} + +/// A drop shadow filter effect (``) +#[derive(Debug, Clone, Copy)] +pub struct FeDropShadow { + /// Horizontal shadow offset in px + pub dx: f32, + + /// Vertical shadow offset in px + pub dy: f32, + + /// Blur radius (`stdDeviation` in SVG) + pub blur: f32, + + /// Shadow color (includes alpha) + pub color: Color, +} + +/// A standalone blur filter effect (``) +#[derive(Debug, Clone, Copy)] +pub struct FeGaussianBlur { + /// Blur radius (`stdDeviation` in SVG) + pub radius: f32, +} + +/// Blend modes for compositing layers, compatible with Skia and SVG/CSS. +/// +/// - SVG: https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/mix-blend-mode +/// - Skia: https://skia.org/docs/user/api/SkBlendMode_Reference/ +/// - Figma: https://help.figma.com/hc/en-us/articles/360039956994 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BlendMode { + // Skia: kSrcOver, CSS: normal + Normal, + + // Skia: kMultiply + Multiply, + // Skia: kScreen + Screen, + // Skia: kOverlay + Overlay, + // Skia: kDarken + Darken, + // Skia: kLighten + Lighten, + // Skia: kColorDodge + ColorDodge, + // Skia: kColorBurn + ColorBurn, + // Skia: kHardLight + HardLight, + // Skia: kSoftLight + SoftLight, + // Skia: kDifference + Difference, + // Skia: kExclusion + Exclusion, + // Skia: kHue + Hue, + // Skia: kSaturation + Saturation, + // Skia: kColor + Color, + // Skia: kLuminosity + Luminosity, + + /// Like `Normal`, but means no blending at all (pass-through). + /// This is Figma-specific, and typically treated the same as `Normal`. + PassThrough, +} + +impl From for skia_safe::BlendMode { + fn from(mode: BlendMode) -> Self { + use skia_safe::BlendMode::*; + match mode { + BlendMode::Normal => SrcOver, + BlendMode::Multiply => Multiply, + BlendMode::Screen => Screen, + BlendMode::Overlay => Overlay, + BlendMode::Darken => Darken, + BlendMode::Lighten => Lighten, + BlendMode::ColorDodge => ColorDodge, + BlendMode::ColorBurn => ColorBurn, + BlendMode::HardLight => HardLight, + BlendMode::SoftLight => SoftLight, + BlendMode::Difference => Difference, + BlendMode::Exclusion => Exclusion, + BlendMode::Hue => Hue, + BlendMode::Saturation => Saturation, + BlendMode::Color => Color, + BlendMode::Luminosity => Luminosity, + BlendMode::PassThrough => SrcOver, // fallback + } + } +} + +/// Text Transform (Text Case) +/// - [MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/text-transform) +#[derive(Debug, Clone, Copy, Deserialize)] +pub enum TextTransform { + #[serde(rename = "none")] + None, + #[serde(rename = "uppercase")] + Uppercase, + #[serde(rename = "lowercase")] + Lowercase, + #[serde(rename = "capitalize")] + Capitalize, +} + +/// Supported text decoration modes. +/// +/// Only `Underline` and `None` are supported in the current version. +/// +/// - [Flutter](https://api.flutter.dev/flutter/dart-ui/TextDecoration-class.html) +/// - [MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/text-decoration) +#[derive(Debug, Clone, Copy, Deserialize)] +pub enum TextDecoration { + #[serde(rename = "none")] + None, + #[serde(rename = "underline")] + Underline, + #[serde(rename = "overline")] + Overline, + #[serde(rename = "line-through")] + LineThrough, +} + +impl From for skia_safe::textlayout::TextDecoration { + fn from(mode: TextDecoration) -> Self { + match mode { + TextDecoration::None => skia_safe::textlayout::TextDecoration::NO_DECORATION, + TextDecoration::Underline => skia_safe::textlayout::TextDecoration::UNDERLINE, + TextDecoration::Overline => skia_safe::textlayout::TextDecoration::OVERLINE, + TextDecoration::LineThrough => skia_safe::textlayout::TextDecoration::LINE_THROUGH, + } + } +} + +/// Supported horizontal text alignment. +/// +/// Does not include `Start` or `End`, as they are not supported currently. +/// +/// - [MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/text-align) +/// - [Flutter](https://api.flutter.dev/flutter/dart-ui/TextAlign.html) +#[derive(Debug, Clone, Copy, Deserialize)] +pub enum TextAlign { + #[serde(rename = "left")] + Left, + #[serde(rename = "right")] + Right, + #[serde(rename = "center")] + Center, + #[serde(rename = "justify")] + Justify, +} + +impl From for skia_safe::textlayout::TextAlign { + fn from(mode: TextAlign) -> Self { + use skia_safe::textlayout::TextAlign::*; + match mode { + TextAlign::Left => Left, + TextAlign::Right => Right, + TextAlign::Center => Center, + TextAlign::Justify => Justify, + } + } +} + +/// Supported vertical alignment values for text. +/// +/// In CSS, this maps to `align-content`. +/// +/// - [MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/align-content) +/// - [Konva](https://konvajs.org/api/Konva.Text.html#verticalAlign) +#[derive(Debug, Clone, Copy, Deserialize)] +pub enum TextAlignVertical { + #[serde(rename = "top")] + Top, + #[serde(rename = "center")] + Center, + #[serde(rename = "bottom")] + Bottom, +} + +/// Font weight value (1-1000). +/// +/// - [MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/font-weight) +/// - [Flutter](https://api.flutter.dev/flutter/dart-ui/FontWeight-class.html) +/// - [OpenType spec](https://learn.microsoft.com/en-us/typography/opentype/spec/os2#usweightclass) +#[derive(Debug, Clone, Copy, Deserialize)] +pub struct FontWeight(pub u32); + +impl FontWeight { + /// Creates a new font weight value. + /// + /// # Arguments + /// + /// * `value` - The font weight value (1-1000) + /// + /// # Panics + /// + /// Panics if the value is not between 1 and 1000. + pub fn new(value: u32) -> Self { + assert!( + value >= 1 && value <= 1000, + "Font weight must be between 1 and 1000" + ); + Self(value) + } + + /// Returns the font weight value. + pub fn value(&self) -> u32 { + self.0 + } + + pub fn default() -> Self { + Self(400) + } +} + +/// A set of style properties that can be applied to a text or text span. +#[derive(Debug, Clone)] +pub struct TextStyle { + /// Text decoration (e.g. underline or none). + pub text_decoration: TextDecoration, + + /// Optional font family name (e.g. "Roboto"). + pub font_family: String, + + /// Font size in logical pixels. + pub font_size: f32, + + /// Font weight (100–900). + pub font_weight: FontWeight, + + /// Font italic style. + pub italic: bool, + + /// Additional spacing between characters, in logical pixels. + /// Default is `0.0`. + pub letter_spacing: Option, + + /// Line height + pub line_height: Option, + + /// Text transform (e.g. uppercase, lowercase, capitalize) + pub text_transform: TextTransform, +} + +#[derive(Debug, Clone, Copy)] +pub struct GradientStop { + /// 0.0 = start, 1.0 = end + pub offset: f32, + pub color: Color, +} + +#[derive(Debug, Clone)] +pub enum Paint { + Solid(SolidPaint), + LinearGradient(LinearGradientPaint), + RadialGradient(RadialGradientPaint), + Image(ImagePaint), +} + +#[derive(Debug, Clone)] +pub struct SolidPaint { + pub color: Color, + pub opacity: f32, +} + +#[derive(Debug, Clone)] +pub struct LinearGradientPaint { + pub transform: AffineTransform, + pub stops: Vec, + pub opacity: f32, +} + +#[derive(Debug, Clone)] +pub struct RadialGradientPaint { + pub transform: AffineTransform, + pub stops: Vec, + pub opacity: f32, +} + +#[derive(Debug, Clone)] +pub struct ImagePaint { + pub transform: AffineTransform, + pub _ref: String, + pub fit: BoxFit, + pub opacity: f32, +} + +#[derive(Debug, Clone)] +pub struct Size { + pub width: f32, + pub height: f32, +} + +#[derive(Debug, Clone, Copy)] +pub struct RectangularCornerRadius { + pub tl: f32, + pub tr: f32, + pub bl: f32, + pub br: f32, +} + +impl RectangularCornerRadius { + pub fn zero() -> Self { + Self::all(0.0) + } + + pub fn all(value: f32) -> Self { + Self { + tl: value, + tr: value, + bl: value, + br: value, + } + } + + pub fn is_zero(&self) -> bool { + self.tl == 0.0 && self.tr == 0.0 && self.bl == 0.0 && self.br == 0.0 + } + + pub fn is_uniform(&self) -> bool { + self.tl == self.tr && self.tl == self.bl && self.tl == self.br + } +} + +// region: Scene +#[derive(Debug, Clone)] +pub struct Scene { + pub id: String, + pub name: String, + pub transform: AffineTransform, + pub children: Vec, + pub nodes: NodeRepository, + pub background_color: Option, +} + +// endregion + +// region: Node Definitions + +#[derive(Debug, Clone)] +pub enum Node { + Error(ErrorNode), + Group(GroupNode), + Container(ContainerNode), + Rectangle(RectangleNode), + Ellipse(EllipseNode), + Polygon(PolygonNode), + RegularPolygon(RegularPolygonNode), + RegularStarPolygon(RegularStarPolygonNode), + Line(LineNode), + TextSpan(TextSpanNode), + Path(PathNode), + BooleanOperation(BooleanPathOperationNode), + Image(ImageNode), +} + +// node trait +pub trait NodeTrait { + fn id(&self) -> NodeId; + fn name(&self) -> String; +} + +impl NodeTrait for Node { + fn id(&self) -> NodeId { + match self { + Node::Error(n) => n.base.id.clone(), + Node::Group(n) => n.base.id.clone(), + Node::Container(n) => n.base.id.clone(), + Node::Rectangle(n) => n.base.id.clone(), + Node::Ellipse(n) => n.base.id.clone(), + Node::Polygon(n) => n.base.id.clone(), + Node::RegularPolygon(n) => n.base.id.clone(), + Node::RegularStarPolygon(n) => n.base.id.clone(), + Node::Line(n) => n.base.id.clone(), + Node::TextSpan(n) => n.base.id.clone(), + Node::Path(n) => n.base.id.clone(), + Node::BooleanOperation(n) => n.base.id.clone(), + Node::Image(n) => n.base.id.clone(), + } + } + + fn name(&self) -> String { + match self { + Node::Error(n) => n.base.name.clone(), + Node::Group(n) => n.base.name.clone(), + Node::Container(n) => n.base.name.clone(), + Node::Rectangle(n) => n.base.name.clone(), + Node::Ellipse(n) => n.base.name.clone(), + Node::Polygon(n) => n.base.name.clone(), + Node::RegularPolygon(n) => n.base.name.clone(), + Node::RegularStarPolygon(n) => n.base.name.clone(), + Node::Line(n) => n.base.name.clone(), + Node::TextSpan(n) => n.base.name.clone(), + Node::Path(n) => n.base.name.clone(), + Node::BooleanOperation(n) => n.base.name.clone(), + Node::Image(n) => n.base.name.clone(), + } + } +} + +/// Intrinsic size node is a node that has a fixed size, and can be rendered soley on its own. +#[derive(Debug, Clone)] +pub enum IntrinsicSizeNode { + Error(ErrorNode), + Container(ContainerNode), + Rectangle(RectangleNode), + Ellipse(EllipseNode), + Polygon(PolygonNode), + RegularPolygon(RegularPolygonNode), + RegularStarPolygon(RegularStarPolygonNode), + Line(LineNode), + TextSpan(TextSpanNode), + Path(PathNode), + Image(ImageNode), +} + +#[derive(Debug, Clone)] +pub enum LeafNode { + Error(ErrorNode), + Rectangle(RectangleNode), + Ellipse(EllipseNode), + Polygon(PolygonNode), + RegularPolygon(RegularPolygonNode), + RegularStarPolygon(RegularStarPolygonNode), + Line(LineNode), + TextSpan(TextSpanNode), + Path(PathNode), + Image(ImageNode), +} + +#[derive(Debug, Clone)] +pub struct BaseNode { + pub id: NodeId, + pub name: String, + pub active: bool, +} + +#[derive(Debug, Clone)] +pub struct ErrorNode { + pub base: BaseNode, + pub transform: AffineTransform, + pub size: Size, + pub error: String, + pub opacity: f32, +} + +impl ErrorNode { + pub fn rect(&self) -> Rectangle { + Rectangle { + x: 0.0, + y: 0.0, + width: self.size.width, + height: self.size.height, + } + } +} + +#[derive(Debug, Clone)] +pub struct GroupNode { + pub base: BaseNode, + pub transform: AffineTransform, + pub children: Vec, + pub opacity: f32, + pub blend_mode: BlendMode, +} + +#[derive(Debug, Clone)] +pub struct ContainerNode { + pub base: BaseNode, + pub transform: AffineTransform, + pub size: Size, + pub corner_radius: RectangularCornerRadius, + pub children: Vec, + pub fill: Paint, + pub stroke: Option, + pub stroke_width: f32, + pub stroke_align: StrokeAlign, + pub stroke_dash_array: Option>, + pub opacity: f32, + pub blend_mode: BlendMode, + pub effect: Option, + pub clip: bool, +} + +impl ContainerNode { + pub fn rect(&self) -> Rectangle { + Rectangle { + x: 0.0, + y: 0.0, + width: self.size.width, + height: self.size.height, + } + } +} + +#[derive(Debug, Clone)] +pub struct RectangleNode { + pub base: BaseNode, + pub transform: AffineTransform, + pub size: Size, + pub corner_radius: RectangularCornerRadius, + pub fill: Paint, + pub stroke: Paint, + pub stroke_width: f32, + pub stroke_align: StrokeAlign, + pub stroke_dash_array: Option>, + pub opacity: f32, + pub blend_mode: BlendMode, + pub effect: Option, +} + +impl RectangleNode { + pub fn rect(&self) -> Rectangle { + Rectangle { + x: 0.0, + y: 0.0, + width: self.size.width, + height: self.size.height, + } + } +} + +#[derive(Debug, Clone)] +pub struct LineNode { + pub base: BaseNode, + pub transform: AffineTransform, + pub size: Size, // height is always 0 (ignored) + pub stroke: Paint, + pub stroke_width: f32, + pub stroke_align: StrokeAlign, + pub stroke_dash_array: Option>, + pub opacity: f32, + pub blend_mode: BlendMode, +} + +#[derive(Debug, Clone)] +pub struct ImageNode { + pub base: BaseNode, + pub transform: AffineTransform, + pub size: Size, + pub corner_radius: RectangularCornerRadius, + pub fill: Paint, + pub stroke: Paint, + pub stroke_width: f32, + pub stroke_align: StrokeAlign, + pub stroke_dash_array: Option>, + pub opacity: f32, + pub blend_mode: BlendMode, + pub effect: Option, + pub _ref: String, +} + +impl ImageNode { + pub fn rect(&self) -> Rectangle { + Rectangle { + x: 0.0, + y: 0.0, + width: self.size.width, + height: self.size.height, + } + } +} + +/// A node representing an ellipse shape. +/// +/// Like RectangleNode, uses a top-left based coordinate system (x,y,width,height). +/// The ellipse is drawn within the bounding box defined by these coordinates. +#[derive(Debug, Clone)] +pub struct EllipseNode { + pub base: BaseNode, + pub transform: AffineTransform, + pub size: Size, + pub fill: Paint, + pub stroke: Paint, + pub stroke_width: f32, + pub stroke_align: StrokeAlign, + pub stroke_dash_array: Option>, + pub opacity: f32, + pub blend_mode: BlendMode, + pub effect: Option, +} + +impl EllipseNode { + pub fn rect(&self) -> Rectangle { + Rectangle { + x: 0.0, + y: 0.0, + width: self.size.width, + height: self.size.height, + } + } +} + +#[derive(Debug, Clone)] +pub struct BooleanPathOperationNode { + pub base: BaseNode, + pub transform: AffineTransform, + pub op: BooleanPathOperation, + pub children: Vec, + pub fill: Paint, + pub stroke: Option, + pub stroke_width: f32, + pub stroke_align: StrokeAlign, + pub stroke_dash_array: Option>, + pub opacity: f32, + pub blend_mode: BlendMode, + pub effect: Option, +} + +/// +/// SVG Path compatible path node. +/// +#[derive(Debug, Clone)] +pub struct PathNode { + pub base: BaseNode, + pub transform: AffineTransform, + pub fill: Paint, + pub data: String, + pub stroke: Paint, + pub stroke_width: f32, + pub stroke_align: StrokeAlign, + pub stroke_dash_array: Option>, + pub opacity: f32, + pub blend_mode: BlendMode, + pub effect: Option, +} + +/// A polygon shape defined by a list of absolute 2D points, following the SVG `` model. +/// +/// ## Characteristics +/// - Always **closed**: The shape is implicitly closed by connecting the last point back to the first. +/// - For **open shapes**, use a different type such as [`PathNode`] or a potential `PolylineNode`. +/// +/// ## Reference +/// Mirrors the behavior of the SVG `` element: +/// https://developer.mozilla.org/en-US/docs/Web/SVG/Element/polygon +#[derive(Debug, Clone)] +pub struct PolygonNode { + /// Common base metadata and identity. + pub base: BaseNode, + + /// 2D affine transform matrix applied to the shape. + pub transform: AffineTransform, + + /// The list of points defining the polygon vertices. + pub points: Vec, + + /// The corner radius of the polygon. + pub corner_radius: f32, + + /// The paint used to fill the interior of the polygon. + pub fill: Paint, + + /// The stroke paint used to outline the polygon. + pub stroke: Paint, + + /// The stroke width used to outline the polygon. + pub stroke_width: f32, + pub stroke_align: StrokeAlign, + + /// Opacity applied to the polygon shape (`0.0` - transparent, `1.0` - opaque). + pub opacity: f32, + pub blend_mode: BlendMode, + pub effect: Option, + pub stroke_dash_array: Option>, +} + +impl PolygonNode { + pub fn to_path(&self) -> skia_safe::Path { + cvt::sk_polygon_path(&self.points, self.corner_radius) + } +} + +/// A node representing a regular polygon (triangle, square, pentagon, etc.) +/// that fits inside a bounding box defined by `size`, optionally transformed. +/// +/// The polygon is defined by `point_count` (number of sides), and is centered +/// within the box, with even and odd point counts having slightly different +/// initial orientations: +/// - Odd `point_count` (e.g. triangle) aligns the top point to the vertical center top. +/// - Even `point_count` aligns the top edge flat. +/// +/// The actual rendering is derived, not stored. Rotation should be applied via `transform`. +/// +/// For details on regular polygon mathematics, see: (implementation varies) +#[derive(Debug, Clone)] +pub struct RegularPolygonNode { + /// Core identity + metadata + pub base: BaseNode, + + /// Affine transform applied to this node + pub transform: AffineTransform, + + /// Bounding box size the polygon is fit into + pub size: Size, + + /// Number of equally spaced points (>= 3) + pub point_count: usize, + + /// The corner radius of the polygon. + pub corner_radius: f32, + + /// Fill paint (solid or gradient) + pub fill: Paint, + + /// The stroke paint used to outline the polygon. + pub stroke: Paint, + + /// The stroke width used to outline the polygon. + pub stroke_width: f32, + pub stroke_align: StrokeAlign, + /// Overall node opacity (0.0–1.0) + pub opacity: f32, + pub blend_mode: BlendMode, + pub effect: Option, + pub stroke_dash_array: Option>, +} + +impl RegularPolygonNode { + pub fn rect(&self) -> Rectangle { + Rectangle { + x: 0.0, + y: 0.0, + width: self.size.width, + height: self.size.height, + } + } + + pub fn to_polygon(&self) -> PolygonNode { + let w = self.size.width; + let h = self.size.height; + let cx = w / 2.0; + let cy = h / 2.0; + let r = w.min(h) / 2.0; + let angle_offset = if self.point_count % 2 == 0 { + std::f32::consts::PI / self.point_count as f32 + } else { + -std::f32::consts::PI / 2.0 + }; + + let points: Vec = (0..self.point_count) + .map(|i| { + let theta = (i as f32 / self.point_count as f32) * 2.0 * std::f32::consts::PI + + angle_offset; + let x = cx + r * theta.cos(); + let y = cy + r * theta.sin(); + Point { x, y } + }) + .collect(); + + PolygonNode { + base: self.base.clone(), + transform: self.transform, + points, + corner_radius: self.corner_radius, + fill: self.fill.clone(), + stroke: self.stroke.clone(), + stroke_width: self.stroke_width, + stroke_align: self.stroke_align, + opacity: self.opacity, + blend_mode: self.blend_mode, + effect: self.effect.clone(), + stroke_dash_array: self.stroke_dash_array.clone(), + } + } +} + +/// A regular star polygon node rendered within a bounding box. +/// +/// This node represents a geometric star shape composed of alternating outer and inner vertices evenly spaced around a center, +/// forming a symmetric star with `point_count` spikes. Each spike is constructed by alternating between an outer point +/// (determined by the bounding box) and an inner point (scaled by `inner_radius`). +/// +/// For details on star polygon mathematics, see: +#[derive(Debug, Clone)] +pub struct RegularStarPolygonNode { + /// Core identity + metadata + pub base: BaseNode, + + /// Affine transform applied to this node + pub transform: AffineTransform, + + /// Bounding box size the polygon is fit into + pub size: Size, + + /// Number of equally spaced points (>= 3) + pub point_count: usize, + + /// The `inner_radius` defines the radius of the inner vertices of the star, relative to the center. + /// + /// It controls the sharpness of the star's angles: + /// - A smaller value (closer to 0) results in sharper, spikier points. + /// - A larger value (closer to or greater than the outer radius) makes the shape closer to a regular polygon with 2 × point_count edges. + /// + /// The outer radius is defined by the bounding box (`size`), while the `inner_radius` places the inner points on a second concentric circle. + /// Unlike `corner_radius`, which affects the rounding of outer corners, `inner_radius` controls the depth of the inner angles between the points. + pub inner_radius: f32, + + /// The corner radius of the polygon. + pub corner_radius: f32, + + /// Fill paint (solid or gradient) + pub fill: Paint, + + /// The stroke paint used to outline the polygon. + pub stroke: Paint, + + /// The stroke width used to outline the polygon. + pub stroke_width: f32, + pub stroke_align: StrokeAlign, + /// Overall node opacity (0.0–1.0) + pub opacity: f32, + pub blend_mode: BlendMode, + pub effect: Option, + pub stroke_dash_array: Option>, +} + +impl RegularStarPolygonNode { + pub fn rect(&self) -> Rectangle { + Rectangle { + x: 0.0, + y: 0.0, + width: self.size.width, + height: self.size.height, + } + } + + pub fn to_polygon(&self) -> PolygonNode { + let w = self.size.width; + let h = self.size.height; + let cx = w / 2.0; + let cy = h / 2.0; + let outer_r = cx.min(cy); + let inner_r = outer_r * self.inner_radius; + let step = std::f32::consts::PI / self.point_count as f32; + let start_angle = -std::f32::consts::PI / 2.0; + + let mut points = Vec::with_capacity(self.point_count * 2); + for i in 0..(self.point_count * 2) { + let angle = start_angle + i as f32 * step; + let r = if i % 2 == 0 { outer_r } else { inner_r }; + let x = cx + r * angle.cos(); + let y = cy + r * angle.sin(); + points.push(Point { x, y }); + } + + PolygonNode { + base: self.base.clone(), + transform: self.transform, + points, + corner_radius: self.corner_radius, + fill: self.fill.clone(), + stroke: self.stroke.clone(), + stroke_width: self.stroke_width, + stroke_align: self.stroke_align, + opacity: self.opacity, + blend_mode: self.blend_mode, + effect: self.effect.clone(), + stroke_dash_array: self.stroke_dash_array.clone(), + } + } +} + +/// A node representing a plain text block (non-rich). +/// For multi-style content, see `RichTextNode` (not implemented yet). +#[derive(Debug, Clone)] +pub struct TextSpanNode { + /// Metadata and identity. + pub base: BaseNode, + + /// Transform applied to the text container. + pub transform: AffineTransform, + + /// Layout bounds (used for wrapping and alignment). + pub size: Size, + + /// Text content (plain UTF-8). + pub text: String, + + /// Font & fill appearance. + pub text_style: TextStyle, + + /// Horizontal alignment. + pub text_align: TextAlign, + + /// Vertical alignment. + pub text_align_vertical: TextAlignVertical, + + /// Fill paint (solid or gradient) + pub fill: Paint, + + /// Stroke paint (solid or gradient) + pub stroke: Option, + + /// Stroke width + pub stroke_width: Option, + pub stroke_align: StrokeAlign, + /// Overall node opacity. + pub opacity: f32, + pub blend_mode: BlendMode, +} + +#[derive(Debug, Clone)] +#[deprecated(note = "Not implemented yet")] +pub struct TextNode { + pub base: BaseNode, + pub transform: AffineTransform, + pub size: Size, + pub text: String, + pub font_size: f32, + pub fill: Paint, + pub opacity: f32, + pub blend_mode: BlendMode, +} + +// endregion diff --git a/crates/grida-canvas/src/painter/cvt.rs b/crates/grida-canvas/src/painter/cvt.rs new file mode 100644 index 0000000000..f533ace53d --- /dev/null +++ b/crates/grida-canvas/src/painter/cvt.rs @@ -0,0 +1,167 @@ +use crate::node::schema::*; +use skia_safe; + +fn cg_build_gradient_stops( + stops: &[GradientStop], + opacity: f32, +) -> (Vec, Vec) { + let mut colors = Vec::with_capacity(stops.len()); + let mut positions = Vec::with_capacity(stops.len()); + + for stop in stops { + let Color(r, g, b, a) = stop.color; + let alpha = (a as f32 * opacity).round().clamp(0.0, 255.0) as u8; + colors.push(skia_safe::Color::from_argb(alpha, r, g, b)); + positions.push(stop.offset); + } + + (colors, positions) +} + +pub fn sk_matrix(m: [[f32; 3]; 2]) -> skia_safe::Matrix { + let [[a, c, tx], [b, d, ty]] = m; + skia_safe::Matrix::from_affine(&[a, b, c, d, tx, ty]) +} + +pub fn sk_paint(paint: &Paint, opacity: f32, size: (f32, f32)) -> skia_safe::Paint { + let mut skia_paint = skia_safe::Paint::default(); + skia_paint.set_anti_alias(true); + let (width, height) = size; + match paint { + Paint::Solid(solid) => { + let Color(r, g, b, a) = solid.color; + let final_alpha = (a as f32 * opacity * solid.opacity) as u8; + skia_paint.set_color(skia_safe::Color::from_argb(final_alpha, r, g, b)); + } + Paint::LinearGradient(gradient) => { + let (colors, positions) = + cg_build_gradient_stops(&gradient.stops, opacity * gradient.opacity); + if let Some(shader) = skia_safe::Shader::linear_gradient( + ( + skia_safe::Point::new(0.0, 0.0), + skia_safe::Point::new(width, 0.0), + ), + &colors[..], + Some(&positions[..]), + skia_safe::TileMode::Clamp, + None, + Some(&sk_matrix(gradient.transform.matrix)), + ) { + skia_paint.set_shader(shader); + } + } + Paint::RadialGradient(gradient) => { + let (colors, positions) = + cg_build_gradient_stops(&gradient.stops, opacity * gradient.opacity); + let center = skia_safe::Point::new(width / 2.0, height / 2.0); + let radius = width.min(height) / 2.0; + if let Some(shader) = skia_safe::Shader::radial_gradient( + center, + radius, + &colors[..], + Some(&positions[..]), + skia_safe::TileMode::Clamp, + None, + Some(&sk_matrix(gradient.transform.matrix)), + ) { + skia_paint.set_shader(shader); + } + } + Paint::Image(image_paint) => { + // For image paints, we just set the opacity since the actual drawing + // is handled by draw_image_rect in the draw_fill_and_stroke method + let final_alpha = (opacity * image_paint.opacity * 255.0) as u8; + skia_paint.set_alpha(final_alpha); + } + } + skia_paint +} + +// pub fn sk_paint_with_stroke( +// paint: &Paint, +// opacity: f32, +// size: (f32, f32), +// stroke_width: f32, +// _stroke_align: StrokeAlign, +// stroke_dash_array: Option<&Vec>, +// ) -> skia_safe::Paint { +// let mut paint = sk_paint(paint, opacity, size); +// paint.set_stroke(true); +// paint.set_stroke_width(stroke_width); + +// // Apply dash pattern if present +// if let Some(dash_array) = stroke_dash_array { +// if let Some(path_effect) = skia_safe::dash_path_effect::new(dash_array, 0.0) { +// paint.set_path_effect(path_effect); +// } +// } + +// paint +// } + +// Given: +// - `pts`: Vec with your polygon's vertices in order +// - `r`: the corner‐radius +// +// Build a Path that walks each edge but rounds each "sharp" corner: +pub fn sk_polygon_path(pts: &[Point], r: f32) -> skia_safe::Path { + let n = pts.len(); + assert!(n >= 3); + + let mut path = skia_safe::Path::new(); + + // Start at the first vertex, but moveTo a point + // that's `r` away from the first corner along the first edge. + // (We'll compute those "offset" points below.) + + // Compute the "offset" point on the last edge that leads into pts[0]: + let last = pts[n - 1]; + let first = pts[0]; + + // 1) Find direction from last→first, then move `r` along that: + let dir_a = Point { + x: (first.x - last.x) / ((first.x - last.x).powi(2) + (first.y - last.y).powi(2)).sqrt(), + y: (first.y - last.y) / ((first.x - last.x).powi(2) + (first.y - last.y).powi(2)).sqrt(), + }; + let move_into_first = first.subtract_scaled(dir_a, r); + + path.move_to(skia_safe::Point::new(move_into_first.x, move_into_first.y)); + + for i in 0..n { + // Current "corner" is pts[i], + // "incoming" edge is (pts[i−1] → pts[i]), + // "outgoing" edge is (pts[i] → pts[i+1]). + let curr = pts[i]; + let prev = pts[(i + n - 1) % n]; + let next = pts[(i + 1) % n]; + + // Compute offset along incoming edge (to where arc starts): + let dir_in = Point { + x: (curr.x - prev.x) / ((curr.x - prev.x).powi(2) + (curr.y - prev.y).powi(2)).sqrt(), + y: (curr.y - prev.y) / ((curr.x - prev.x).powi(2) + (curr.y - prev.y).powi(2)).sqrt(), + }; + let start_arc = curr.subtract_scaled(dir_in, r); + + // Compute offset along outgoing edge (to where arc ends): + let dir_out = Point { + x: (next.x - curr.x) / ((next.x - curr.x).powi(2) + (next.y - curr.y).powi(2)).sqrt(), + y: (next.y - curr.y) / ((next.x - curr.x).powi(2) + (next.y - curr.y).powi(2)).sqrt(), + }; + let end_arc = Point { + x: curr.x + dir_out.x * r, + y: curr.y + dir_out.y * r, + }; + + // Line from previous offset → start_arc + path.line_to(skia_safe::Point::new(start_arc.x, start_arc.y)); + + // Add the rounded corner (arc) from start_arc → end_arc, tangent at curr: + path.quad_to( + skia_safe::Point::new(curr.x, curr.y), + skia_safe::Point::new(end_arc.x, end_arc.y), + ); + } + + path.close(); + path +} diff --git a/crates/grida-canvas/src/painter/geometry.rs b/crates/grida-canvas/src/painter/geometry.rs new file mode 100644 index 0000000000..9b37ce3ffe --- /dev/null +++ b/crates/grida-canvas/src/painter/geometry.rs @@ -0,0 +1,408 @@ +use crate::cache::geometry::GeometryCache; +use crate::node::repository::NodeRepository; +use crate::node::schema::*; +use crate::painter::cvt; +use math2::transform::AffineTransform; +use skia_safe::{ + Path, PathOp, Point, RRect, Rect, StrokeRec, path_effect::PathEffect, stroke_rec::InitStyle, +}; + +/// Computes the stroke geometry path for a given input `Path`, enabling rich stroke +/// rendering features such as image fills, gradients, and complex stroke alignment. +/// +/// This function generates a *filled path* that visually represents the stroke outline, +/// based on stroke width, alignment, and optional dash pattern. The result can be used +/// with any fill-based rendering pipeline, e.g. image shaders, gradients, or masking. +/// +/// # Parameters +/// +/// - `source_path`: The original vector path to be stroked. +/// - `stroke_width`: The stroke width (measured in logical pixels). +/// - `stroke_align`: Controls how the stroke is aligned relative to the path. +/// - `StrokeAlign::Center`: Stroke is centered on the path (default Skia behavior). +/// - `StrokeAlign::Inside`: Stroke lies entirely inside the path boundary. +/// - `StrokeAlign::Outside`: Stroke lies entirely outside the path boundary. +/// - `stroke_dash_array`: Optional dash pattern (e.g., `[10.0, 4.0]` for 10 on, 4 off). +/// +/// # Returns +/// +/// A `Path` representing the stroke outline as a filled geometry. This path can be used +/// with image or gradient fills, or for clipping, hit-testing, or boolean operations. +/// +/// # Behavior +/// +/// - If `stroke_align` is not `Center`, the result uses boolean path operations to clip or subtract +/// the stroke geometry relative to the original path. +/// - If a dash array is provided, it is applied before stroking. +/// - If the path is empty or invalid, an empty `Path` is returned. +/// +/// # Example +/// +/// ```rust,ignore +/// let stroke_path = stroke_geometry( +/// &original_path, +/// 4.0, +/// StrokeAlign::Inside, +/// Some(&vec![8.0, 4.0]) +/// ); +/// canvas.draw_path(&stroke_path, &image_paint); +/// ``` +/// +/// # See Also +/// +/// - [`SkStrokeRec`](https://github.com/google/skia/blob/main/include/core/SkStrokeRec.h) +/// - [`SkPath::op`](https://github.com/google/skia/blob/main/include/core/SkPath.h) +/// - [`SkDashPathEffect`](https://github.com/google/skia/blob/main/include/effects/SkDashPathEffect.h) +pub fn stroke_geometry( + source_path: &Path, + stroke_width: f32, + stroke_align: StrokeAlign, + stroke_dash_array: Option<&Vec>, +) -> Path { + use StrokeAlign::*; + + let adjusted_width = match stroke_align { + Center => stroke_width, + Inside => stroke_width * 2.0, // we'll clip it later + Outside => stroke_width * 2.0, // we'll subtract later + }; + + // Create a stroke record with the adjusted width + let mut stroke_rec = StrokeRec::new(InitStyle::Hairline); + stroke_rec.set_stroke_style(adjusted_width, false); + + // Apply dash effect if provided + let mut path_to_stroke = source_path.clone(); + if let Some(dashes) = stroke_dash_array { + if let Some(pe) = PathEffect::dash(dashes, 0.0) { + if let Some((dashed, _)) = + pe.filter_path(source_path, &stroke_rec, source_path.bounds()) + { + path_to_stroke = dashed; + } + } + } + + // Apply the stroke to create the outline + let mut stroked_path = Path::new(); + if stroke_rec.apply_to_path(&mut stroked_path, &path_to_stroke) { + match stroke_align { + Center => stroked_path, + Inside => { + // Clip to original path: intersection + if let Some(result) = Path::op(&stroked_path, source_path, PathOp::Intersect) { + result + } else { + stroked_path + } + } + Outside => { + // Subtract original path from stroke outline + if let Some(result) = Path::op(&stroked_path, source_path, PathOp::Difference) { + result + } else { + stroked_path + } + } + } + } else { + Path::new() + } +} + +/// Internal universal Painter's shape abstraction for optimized drawing +/// Virtual nodes like Group, BooleanOperation are not Painter's shapes, they use different methods. +#[derive(Debug, Clone)] +pub struct PainterShape { + pub rect: Rect, + pub rect_shape: Option, + pub rrect: Option, + pub oval: Option, + pub path: Option, +} + +impl PainterShape { + /// Construct a plain rectangle shape + pub fn from_rect(rect: Rect) -> Self { + Self { + rect, + rect_shape: Some(rect), + rrect: None, + oval: None, + path: None, + } + } + /// Construct a rounded rectangle shape + pub fn from_rrect(rrect: RRect) -> Self { + Self { + rect: rrect.rect().clone(), + rect_shape: None, + rrect: Some(rrect), + oval: None, + path: None, + } + } + /// Construct an oval/ellipse shape + pub fn from_oval(rect: Rect) -> Self { + Self { + rect, + rect_shape: None, + rrect: None, + oval: Some(rect), + path: None, + } + } + /// Construct a path-based shape (bounding rect must be provided) + pub fn from_path(path: Path) -> Self { + Self { + rect: path.bounds().clone(), + rect_shape: None, + rrect: None, + oval: None, + path: Some(path), + } + } + + pub fn to_path(&self) -> Path { + let mut path = Path::new(); + + if let Some(rect) = self.rect_shape { + path.add_rect(rect, None); + } else if let Some(rrect) = &self.rrect { + path.add_rrect(rrect, None); + } else if let Some(oval) = &self.oval { + path.add_oval(oval, None); + } else if let Some(existing_path) = &self.path { + path = existing_path.clone(); + } else { + // Fallback to rect if no specific shape is set + path.add_rect(self.rect, None); + } + + path + } +} + +pub fn build_shape(node: &IntrinsicSizeNode) -> PainterShape { + match node { + IntrinsicSizeNode::Rectangle(n) => { + let rect = Rect::from_xywh(0.0, 0.0, n.size.width, n.size.height); + let r = n.corner_radius; + if !r.is_zero() { + let rrect = RRect::new_rect_radii( + rect, + &[ + Point::new(r.tl, r.tl), + Point::new(r.tr, r.tr), + Point::new(r.br, r.br), + Point::new(r.bl, r.bl), + ], + ); + PainterShape::from_rrect(rrect) + } else { + PainterShape::from_rect(rect) + } + } + IntrinsicSizeNode::Ellipse(n) => { + let rect = Rect::from_xywh(0.0, 0.0, n.size.width, n.size.height); + PainterShape::from_oval(rect) + } + IntrinsicSizeNode::Polygon(n) => { + let path = if n.corner_radius > 0.0 { + n.to_path() + } else { + let mut p = Path::new(); + let mut iter = n.points.iter(); + if let Some(&pt) = iter.next() { + p.move_to((pt.x, pt.y)); + for &pt in iter { + p.line_to((pt.x, pt.y)); + } + p.close(); + } + p + }; + PainterShape::from_path(path) + } + IntrinsicSizeNode::RegularPolygon(n) => { + let poly = n.to_polygon(); + build_shape(&IntrinsicSizeNode::Polygon(poly)) + } + IntrinsicSizeNode::RegularStarPolygon(n) => { + let poly = n.to_polygon(); + build_shape(&IntrinsicSizeNode::Polygon(poly)) + } + IntrinsicSizeNode::Line(n) => { + let mut path = Path::new(); + path.move_to((0.0, 0.0)); + path.line_to((n.size.width, 0.0)); + PainterShape::from_path(path) + } + IntrinsicSizeNode::Path(n) => { + if let Some(path) = Path::from_svg(&n.data) { + PainterShape::from_path(path) + } else { + // Fallback to empty rect if path is invalid + PainterShape::from_rect(Rect::new(0.0, 0.0, 0.0, 0.0)) + } + } + IntrinsicSizeNode::Container(n) => { + let rect = Rect::from_xywh(0.0, 0.0, n.size.width, n.size.height); + let r = n.corner_radius; + if r.tl > 0.0 || r.tr > 0.0 || r.bl > 0.0 || r.br > 0.0 { + let rrect = RRect::new_rect_radii( + rect, + &[ + Point::new(r.tl, r.tl), + Point::new(r.tr, r.tr), + Point::new(r.br, r.br), + Point::new(r.bl, r.bl), + ], + ); + PainterShape::from_rrect(rrect) + } else { + PainterShape::from_rect(rect) + } + } + IntrinsicSizeNode::Image(n) => { + let rect = Rect::from_xywh(0.0, 0.0, n.size.width, n.size.height); + let r = n.corner_radius; + if r.tl > 0.0 || r.tr > 0.0 || r.bl > 0.0 || r.br > 0.0 { + let rrect = RRect::new_rect_radii( + rect, + &[ + Point::new(r.tl, r.tl), + Point::new(r.tr, r.tr), + Point::new(r.br, r.br), + Point::new(r.bl, r.bl), + ], + ); + PainterShape::from_rrect(rrect) + } else { + PainterShape::from_rect(rect) + } + } + IntrinsicSizeNode::Error(n) => { + let rect = Rect::from_xywh(0.0, 0.0, n.size.width, n.size.height); + PainterShape::from_rect(rect) + } + IntrinsicSizeNode::TextSpan(n) => { + // Text spans don't have a shape + PainterShape::from_rect(Rect::new(0.0, 0.0, n.size.width, n.size.height)) + } + } +} + +/// Merges multiple shapes into a single path using boolean operations. +/// +/// This function takes a list of shapes and their corresponding boolean operations, +/// and merges them into a single path. The first shape is used as the base, +/// and subsequent shapes are combined using the specified operations. +/// +/// # Parameters +/// +/// - `shapes`: A slice of tuples containing (PainterShape, BooleanPathOperation) +/// The first shape is used as the base, subsequent shapes are combined with the base +/// using their respective operations. +/// +/// # Returns +/// +/// A merged `Path` representing the result of all boolean operations. +/// If no shapes are provided, returns an empty path. +/// +/// # Example +/// +/// ```rust,ignore +/// let shapes = vec![ +/// (shape1, BooleanPathOperation::Union), +/// (shape2, BooleanPathOperation::Intersection), +/// ]; +/// let merged_path = merge_shapes(&shapes); +/// ``` +pub fn merge_shapes(shapes: &[(PainterShape, BooleanPathOperation)]) -> Path { + if shapes.is_empty() { + return Path::new(); + } + + let mut result = shapes[0].0.to_path(); + + for (shape, operation) in shapes.iter().skip(1) { + let shape_path = shape.to_path(); + if let Some(merged) = Path::op(&result, &shape_path, (*operation).into()) { + result = merged; + } + } + + result +} + +/// Build a [`PainterShape`] for a node if it has intrinsic geometry. +pub fn build_shape_from_node(node: &Node) -> Option { + match node { + Node::Rectangle(n) => Some(build_shape(&IntrinsicSizeNode::Rectangle(n.clone()))), + Node::Ellipse(n) => Some(build_shape(&IntrinsicSizeNode::Ellipse(n.clone()))), + Node::Polygon(n) => Some(build_shape(&IntrinsicSizeNode::Polygon(n.clone()))), + Node::RegularPolygon(n) => Some(build_shape(&IntrinsicSizeNode::RegularPolygon(n.clone()))), + Node::RegularStarPolygon(n) => Some(build_shape(&IntrinsicSizeNode::RegularStarPolygon( + n.clone(), + ))), + Node::Line(n) => Some(build_shape(&IntrinsicSizeNode::Line(n.clone()))), + Node::Path(n) => Some(build_shape(&IntrinsicSizeNode::Path(n.clone()))), + Node::Image(n) => Some(build_shape(&IntrinsicSizeNode::Image(n.clone()))), + Node::Error(n) => Some(build_shape(&IntrinsicSizeNode::Error(n.clone()))), + _ => None, + } +} + +/// Compute the resulting path for a [`BooleanPathOperationNode`] in its local coordinate space. +pub fn boolean_operation_path( + node: &BooleanPathOperationNode, + repo: &NodeRepository, + cache: &GeometryCache, +) -> Option { + let world = cache + .get_world_transform(&node.base.id) + .unwrap_or_else(AffineTransform::identity); + let inv = world.inverse().unwrap_or_else(AffineTransform::identity); + + let mut shapes_with_ops = Vec::new(); + + for (i, child_id) in node.children.iter().enumerate() { + if let Some(child_node) = repo.get(child_id) { + let mut path = match child_node { + Node::BooleanOperation(child_bool) => { + boolean_operation_path(child_bool, repo, cache)? + } + _ => build_shape_from_node(child_node)?.to_path(), + }; + + let child_world = cache + .get_world_transform(child_id) + .unwrap_or_else(AffineTransform::identity); + let relative = inv.compose(&child_world); + path.transform(&cvt::sk_matrix(relative.matrix)); + + let op = if i == 0 { + BooleanPathOperation::Union + } else { + node.op + }; + shapes_with_ops.push((PainterShape::from_path(path), op)); + } + } + + if shapes_with_ops.is_empty() { + return None; + } + + Some(merge_shapes(&shapes_with_ops)) +} + +/// Convenience wrapper around [`boolean_operation_path`] returning a [`PainterShape`]. +pub fn boolean_operation_shape( + node: &BooleanPathOperationNode, + repo: &NodeRepository, + cache: &GeometryCache, +) -> Option { + boolean_operation_path(node, repo, cache).map(PainterShape::from_path) +} diff --git a/crates/grida-canvas/src/painter/layer.rs b/crates/grida-canvas/src/painter/layer.rs new file mode 100644 index 0000000000..63dc4b89e4 --- /dev/null +++ b/crates/grida-canvas/src/painter/layer.rs @@ -0,0 +1,589 @@ +use super::geometry::{ + PainterShape, boolean_operation_path, boolean_operation_shape, build_shape, merge_shapes, + stroke_geometry, +}; +use crate::cache::geometry::GeometryCache; +use crate::node::repository::NodeRepository; +use crate::node::schema::*; +use math2::transform::AffineTransform; +use skia_safe::Path; + +/// A Skia-friendly, cacheable picture layer for vector rendering. +/// +/// `PainterPictureLayer` represents a flattened, self-contained unit of vector draw commands, +/// recorded as a Skia `SkPicture`. It is designed for reuse across multiple frames or draw passes, +/// enabling high-performance rendering via picture caching. +/// +/// This is the first step of isolating draw content from rendering context (transform, opacity, blend), +/// allowing layers to be reused with different composite properties (see `LayerUsage`). +/// +/// ## Characteristics +/// +/// - Contains **pure draw content** (shape, paint, effects) +/// - Does **not** include transform, opacity, blend mode, clip +/// - Can be recorded once and reused as `SkPicture` or rendered live +/// - Effects like blur/shadow are baked into the picture if needed +/// +/// ## Use Cases +/// +/// - Caching static shape trees (e.g. icons, frames, symbols) +/// - Re-recording affected subtrees for dirty region rendering +/// - Serving as a source input for tile-based compositing +/// +/// ## Typical Workflow +/// +/// 1. Compile scene node(s) into a `PainterPictureLayer` +/// 2. Record its content into a `SkPicture` +/// 3. On each frame, draw the cached picture with: +/// - transform +/// - opacity +/// - blend mode +/// +/// ## Example +/// +/// ```rust,ignore +/// // Layer definition +/// let layer = PainterPictureLayer { +/// shape: shape, +/// fills: vec![fill], +/// strokes: vec![stroke], +/// effects: vec![], +/// }; +/// +/// // Record +/// let picture = record_to_sk_picture(&layer); +/// +/// // Use +/// canvas.save(); +/// canvas.concat(transform); +/// canvas.save_layer_alpha(...); +/// canvas.draw_picture(&picture, None, None); +/// canvas.restore(); +/// canvas.restore(); +/// ``` +/// +/// ## See Also +/// - [`LayerUsage`] — carries per-frame composite state (transform, opacity) +/// - [`RenderCommand`] — full rendering instruction with resolved state +/// - [`PainterShape`] — resolved shape geometry abstraction +#[derive(Debug, Clone)] +pub enum PainterPictureLayer { + Shape(PainterPictureShapeLayer), + Text(PainterPictureTextLayer), +} + +pub trait Layer { + fn id(&self) -> &NodeId; + fn z_index(&self) -> usize; +} + +impl Layer for PainterPictureLayer { + fn id(&self) -> &NodeId { + match self { + PainterPictureLayer::Shape(layer) => &layer.base.id, + PainterPictureLayer::Text(layer) => &layer.base.id, + } + } + + fn z_index(&self) -> usize { + match self { + PainterPictureLayer::Shape(layer) => layer.base.z_index, + PainterPictureLayer::Text(layer) => layer.base.z_index, + } + } +} + +#[derive(Debug, Clone)] +pub struct PainterPictureLayerBase { + pub id: NodeId, + pub z_index: usize, + pub opacity: f32, + pub transform: AffineTransform, + pub shape: PainterShape, + pub effects: Vec, + pub strokes: Vec, + pub fills: Vec, + pub stroke_path: Option, + pub clip_path: Option, +} + +#[derive(Debug, Clone)] +pub struct PainterPictureShapeLayer { + pub base: PainterPictureLayerBase, +} + +#[derive(Debug, Clone)] +pub struct PainterPictureTextLayer { + pub base: PainterPictureLayerBase, + pub text: String, + pub text_style: TextStyle, + pub text_align: TextAlign, + pub text_align_vertical: TextAlignVertical, +} + +/// Flat list of [`PainterPictureLayer`] entries. +#[derive(Debug, Default, Clone)] +pub struct LayerList { + pub layers: Vec, +} + +impl LayerList { + /// Flatten an entire scene into a layer list using the provided geometry cache. + pub fn from_scene(scene: &Scene, cache: &GeometryCache) -> Self { + let mut list = LayerList::default(); + for id in &scene.children { + Self::flatten_node(id, &scene.nodes, cache, 1.0, &mut list.layers); + } + list + } + + /// Build a layer list starting from a node subtree using a geometry cache. + pub fn from_node( + id: &NodeId, + repo: &NodeRepository, + cache: &GeometryCache, + opacity: f32, + ) -> Self { + let mut list = LayerList::default(); + Self::flatten_node(id, repo, cache, opacity, &mut list.layers); + list + } + + pub fn len(&self) -> usize { + self.layers.len() + } + + fn flatten_node( + id: &NodeId, + repo: &NodeRepository, + cache: &GeometryCache, + parent_opacity: f32, + out: &mut Vec, + ) { + if let Some(node) = repo.get(id) { + let transform = cache + .get_world_transform(id) + .unwrap_or_else(AffineTransform::identity); + match node { + Node::Group(n) => { + let opacity = parent_opacity * n.opacity; + for child in &n.children { + Self::flatten_node(child, repo, cache, opacity, out); + } + } + Node::Container(n) => { + let opacity = parent_opacity * n.opacity; + let shape = build_shape(&IntrinsicSizeNode::Container(n.clone())); + let stroke_path = if n.stroke.is_some() && n.stroke_width > 0.0 { + Some(stroke_geometry( + &shape.to_path(), + n.stroke_width, + n.stroke_align, + n.stroke_dash_array.as_ref(), + )) + } else { + None + }; + out.push(PainterPictureLayer::Shape(PainterPictureShapeLayer { + base: PainterPictureLayerBase { + id: n.base.id.clone(), + z_index: out.len(), + opacity, + transform, + shape, + effects: n.effect.clone().into_iter().collect(), + strokes: n.stroke.clone().into_iter().collect(), + fills: vec![n.fill.clone()], + stroke_path, + clip_path: Self::compute_clip_path(&n.base.id, repo, cache), + }, + })); + for child in &n.children { + Self::flatten_node(child, repo, cache, opacity, out); + } + } + Node::BooleanOperation(n) => { + let opacity = parent_opacity * n.opacity; + if let Some(shape) = boolean_operation_shape(n, repo, cache) { + let stroke_path = if n.stroke.is_some() && n.stroke_width > 0.0 { + Some(stroke_geometry( + &shape.to_path(), + n.stroke_width, + n.stroke_align, + n.stroke_dash_array.as_ref(), + )) + } else { + None + }; + out.push(PainterPictureLayer::Shape(PainterPictureShapeLayer { + base: PainterPictureLayerBase { + id: n.base.id.clone(), + z_index: out.len(), + opacity, + transform, + shape, + effects: n.effect.clone().into_iter().collect(), + strokes: n.stroke.clone().into_iter().collect(), + fills: vec![n.fill.clone()], + stroke_path, + clip_path: Self::compute_clip_path(&n.base.id, repo, cache), + }, + })); + } else { + for child in &n.children { + Self::flatten_node(child, repo, cache, opacity, out); + } + } + } + Node::Rectangle(n) => { + let shape = build_shape(&IntrinsicSizeNode::Rectangle(n.clone())); + let stroke_path = if n.stroke_width > 0.0 { + Some(stroke_geometry( + &shape.to_path(), + n.stroke_width, + n.stroke_align, + n.stroke_dash_array.as_ref(), + )) + } else { + None + }; + out.push(PainterPictureLayer::Shape(PainterPictureShapeLayer { + base: PainterPictureLayerBase { + id: n.base.id.clone(), + z_index: out.len(), + opacity: parent_opacity * n.opacity, + transform, + shape, + effects: n.effect.clone().into_iter().collect(), + strokes: vec![n.stroke.clone()], + fills: vec![n.fill.clone()], + stroke_path, + clip_path: Self::compute_clip_path(&n.base.id, repo, cache), + }, + })) + } + Node::Ellipse(n) => { + let shape = build_shape(&IntrinsicSizeNode::Ellipse(n.clone())); + let stroke_path = if n.stroke_width > 0.0 { + Some(stroke_geometry( + &shape.to_path(), + n.stroke_width, + n.stroke_align, + n.stroke_dash_array.as_ref(), + )) + } else { + None + }; + out.push(PainterPictureLayer::Shape(PainterPictureShapeLayer { + base: PainterPictureLayerBase { + id: n.base.id.clone(), + z_index: out.len(), + opacity: parent_opacity * n.opacity, + transform, + shape, + effects: n.effect.clone().into_iter().collect(), + strokes: vec![n.stroke.clone()], + fills: vec![n.fill.clone()], + stroke_path, + clip_path: Self::compute_clip_path(&n.base.id, repo, cache), + }, + })) + } + Node::Polygon(n) => { + let shape = build_shape(&IntrinsicSizeNode::Polygon(n.clone())); + let stroke_path = if n.stroke_width > 0.0 { + Some(stroke_geometry( + &shape.to_path(), + n.stroke_width, + n.stroke_align, + n.stroke_dash_array.as_ref(), + )) + } else { + None + }; + out.push(PainterPictureLayer::Shape(PainterPictureShapeLayer { + base: PainterPictureLayerBase { + id: n.base.id.clone(), + z_index: out.len(), + opacity: parent_opacity * n.opacity, + transform, + shape, + effects: n.effect.clone().into_iter().collect(), + strokes: vec![n.stroke.clone()], + fills: vec![n.fill.clone()], + stroke_path, + clip_path: Self::compute_clip_path(&n.base.id, repo, cache), + }, + })) + } + Node::RegularPolygon(n) => { + let shape = build_shape(&IntrinsicSizeNode::RegularPolygon(n.clone())); + let stroke_path = if n.stroke_width > 0.0 { + Some(stroke_geometry( + &shape.to_path(), + n.stroke_width, + n.stroke_align, + n.stroke_dash_array.as_ref(), + )) + } else { + None + }; + out.push(PainterPictureLayer::Shape(PainterPictureShapeLayer { + base: PainterPictureLayerBase { + id: n.base.id.clone(), + z_index: out.len(), + opacity: parent_opacity * n.opacity, + transform, + shape, + effects: n.effect.clone().into_iter().collect(), + strokes: vec![n.stroke.clone()], + fills: vec![n.fill.clone()], + stroke_path, + clip_path: Self::compute_clip_path(&n.base.id, repo, cache), + }, + })) + } + Node::RegularStarPolygon(n) => { + let shape = build_shape(&IntrinsicSizeNode::RegularStarPolygon(n.clone())); + let stroke_path = if n.stroke_width > 0.0 { + Some(stroke_geometry( + &shape.to_path(), + n.stroke_width, + n.stroke_align, + n.stroke_dash_array.as_ref(), + )) + } else { + None + }; + out.push(PainterPictureLayer::Shape(PainterPictureShapeLayer { + base: PainterPictureLayerBase { + id: n.base.id.clone(), + z_index: out.len(), + opacity: parent_opacity * n.opacity, + transform, + shape, + effects: n.effect.clone().into_iter().collect(), + strokes: vec![n.stroke.clone()], + fills: vec![n.fill.clone()], + stroke_path, + clip_path: Self::compute_clip_path(&n.base.id, repo, cache), + }, + })) + } + Node::Line(n) => { + let shape = build_shape(&IntrinsicSizeNode::Line(n.clone())); + let stroke_path = if n.stroke_width > 0.0 { + Some(stroke_geometry( + &shape.to_path(), + n.stroke_width, + n.stroke_align, + n.stroke_dash_array.as_ref(), + )) + } else { + None + }; + out.push(PainterPictureLayer::Shape(PainterPictureShapeLayer { + base: PainterPictureLayerBase { + id: n.base.id.clone(), + z_index: out.len(), + opacity: parent_opacity * n.opacity, + transform, + shape, + effects: vec![], + strokes: vec![n.stroke.clone()], + fills: vec![], + stroke_path, + clip_path: Self::compute_clip_path(&n.base.id, repo, cache), + }, + })) + } + Node::TextSpan(n) => out.push(PainterPictureLayer::Text(PainterPictureTextLayer { + base: PainterPictureLayerBase { + id: n.base.id.clone(), + z_index: out.len(), + opacity: parent_opacity * n.opacity, + transform, + shape: build_shape(&IntrinsicSizeNode::TextSpan(n.clone())), + effects: vec![], + strokes: n.stroke.clone().into_iter().collect(), + fills: vec![n.fill.clone()], + stroke_path: None, + clip_path: Self::compute_clip_path(&n.base.id, repo, cache), + }, + text: n.text.clone(), + text_style: n.text_style.clone(), + text_align: n.text_align, + text_align_vertical: n.text_align_vertical, + })), + Node::Path(n) => { + let shape = build_shape(&IntrinsicSizeNode::Path(n.clone())); + let stroke_path = if n.stroke_width > 0.0 { + Some(stroke_geometry( + &shape.to_path(), + n.stroke_width, + n.stroke_align, + n.stroke_dash_array.as_ref(), + )) + } else { + None + }; + out.push(PainterPictureLayer::Shape(PainterPictureShapeLayer { + base: PainterPictureLayerBase { + id: n.base.id.clone(), + z_index: out.len(), + opacity: parent_opacity * n.opacity, + transform, + shape, + effects: n.effect.clone().into_iter().collect(), + strokes: vec![n.stroke.clone()], + fills: vec![n.fill.clone()], + stroke_path, + clip_path: Self::compute_clip_path(&n.base.id, repo, cache), + }, + })) + } + Node::Image(n) => { + let shape = build_shape(&IntrinsicSizeNode::Image(n.clone())); + let stroke_path = if n.stroke_width > 0.0 { + Some(stroke_geometry( + &shape.to_path(), + n.stroke_width, + n.stroke_align, + n.stroke_dash_array.as_ref(), + )) + } else { + None + }; + out.push(PainterPictureLayer::Shape(PainterPictureShapeLayer { + base: PainterPictureLayerBase { + id: n.base.id.clone(), + z_index: out.len(), + opacity: parent_opacity * n.opacity, + transform, + shape, + effects: n.effect.clone().into_iter().collect(), + strokes: vec![n.stroke.clone()], + fills: vec![n.fill.clone()], + stroke_path, + clip_path: Self::compute_clip_path(&n.base.id, repo, cache), + }, + })) + } + Node::Error(n) => out.push(PainterPictureLayer::Shape(PainterPictureShapeLayer { + base: PainterPictureLayerBase { + id: n.base.id.clone(), + z_index: out.len(), + opacity: parent_opacity * n.opacity, + transform, + shape: build_shape(&IntrinsicSizeNode::Error(n.clone())), + effects: vec![], + strokes: vec![], + fills: vec![], + stroke_path: None, + clip_path: Self::compute_clip_path(&n.base.id, repo, cache), + }, + })), + } + } + } + + pub fn filter(&self, filter: impl Fn(&PainterPictureLayer) -> bool) -> Self { + let mut list = LayerList::default(); + for layer in &self.layers { + if filter(layer) { + list.layers.push(layer.clone()); + } + } + list + } + + /// Computes the clip path for a node by traversing up the hierarchy + /// and collecting all clip shapes from parent nodes. + /// + /// This function walks up the node tree starting from the given node ID, + /// collecting shapes from parent nodes that have `clip = true`. + /// The shapes are merged using boolean operations to create a single clip path. + /// + /// # Parameters + /// + /// - `node_id`: The ID of the node to compute the clip path for + /// - `repo`: The node repository containing all nodes + /// - `cache`: The geometry cache for transforms + /// + /// # Returns + /// + /// An `Option` representing the merged clip path, or `None` if no clipping is needed. + pub fn compute_clip_path( + node_id: &NodeId, + repo: &NodeRepository, + cache: &GeometryCache, + ) -> Option { + let mut clip_shapes = Vec::new(); + let mut current_id = Some(node_id.clone()); + + let current_world = cache + .get_world_transform(node_id) + .unwrap_or_else(AffineTransform::identity); + let current_inv = current_world + .inverse() + .unwrap_or_else(AffineTransform::identity); + + // Walk up the hierarchy to collect clip shapes + while let Some(id) = current_id { + if let Some(node) = repo.get(&id) { + match node { + Node::Container(n) => { + if n.clip { + // Get the world transform for this node + let world_transform = cache + .get_world_transform(&id) + .unwrap_or_else(AffineTransform::identity); + + // Build the shape and transform it relative to the current node + let shape = build_shape(&IntrinsicSizeNode::Container(n.clone())); + let mut path = shape.to_path(); + let relative_transform = current_inv.compose(&world_transform); + path.transform(&crate::painter::cvt::sk_matrix( + relative_transform.matrix, + )); + + clip_shapes.push(( + PainterShape::from_path(path), + BooleanPathOperation::Intersection, + )); + } + } + Node::BooleanOperation(n) => { + if let Some(mut path) = boolean_operation_path(n, repo, cache) { + let world_transform = cache + .get_world_transform(&id) + .unwrap_or_else(AffineTransform::identity); + let relative_transform = current_inv.compose(&world_transform); + path.transform(&crate::painter::cvt::sk_matrix( + relative_transform.matrix, + )); + + clip_shapes.push(( + PainterShape::from_path(path), + BooleanPathOperation::Intersection, + )); + } + } + _ => {} // Skip other node types + } + + // Move up to parent + current_id = cache.get_parent(&id); + } else { + break; + } + } + + // If we have clip shapes, merge them + if !clip_shapes.is_empty() { + Some(merge_shapes(&clip_shapes)) + } else { + None + } + } +} diff --git a/crates/grida-canvas/src/painter/mod.rs b/crates/grida-canvas/src/painter/mod.rs new file mode 100644 index 0000000000..00a27179f3 --- /dev/null +++ b/crates/grida-canvas/src/painter/mod.rs @@ -0,0 +1,5 @@ +mod painter; +pub use painter::*; +pub mod cvt; +pub mod geometry; +pub mod layer; diff --git a/crates/grida-canvas/src/painter/painter.rs b/crates/grida-canvas/src/painter/painter.rs new file mode 100644 index 0000000000..e365eceb46 --- /dev/null +++ b/crates/grida-canvas/src/painter/painter.rs @@ -0,0 +1,830 @@ +use super::cvt; +use super::geometry::*; +use super::layer::{LayerList, PainterPictureLayer}; +use crate::cache::geometry::GeometryCache; +use crate::node::repository::NodeRepository; +use crate::node::schema::*; +use crate::repository::{FontRepository, ImageRepository}; +use math2::{box_fit::BoxFit, transform::AffineTransform}; +use skia_safe::{Paint as SkPaint, Point, canvas::SaveLayerRec, textlayout}; +use std::cell::RefCell; +use std::rc::Rc; + +/// A painter that handles all drawing operations for nodes, +/// with proper effect ordering and a layer‐blur/backdrop‐blur pipeline. +pub struct Painter<'a> { + canvas: &'a skia_safe::Canvas, + fonts: Rc>, + images: Rc>, +} + +impl<'a> Painter<'a> { + /// Create a new Painter for the given canvas + pub fn new( + canvas: &'a skia_safe::Canvas, + fonts: Rc>, + images: Rc>, + ) -> Self { + Self { + canvas, + fonts, + images, + } + } + + // ============================ + // === Helper Methods ======== + // ============================ + + /// Save/restore transform state and apply a 2×3 matrix + fn with_transform(&self, transform: &[[f32; 3]; 2], f: F) { + let canvas = self.canvas; + canvas.save(); + canvas.concat(&cvt::sk_matrix(*transform)); + f(); + canvas.restore(); + } + + /// If opacity < 1.0, wrap drawing in a save_layer_alpha; else draw directly. + fn with_opacity(&self, opacity: f32, f: F) { + let canvas = self.canvas; + if opacity < 1.0 { + canvas.save_layer_alpha(None, (opacity * 255.0) as u32); + f(); + canvas.restore(); + } else { + f(); + } + } + + /// If blend mode is not Normal, wrap drawing in a save_layer with blend mode; else draw directly. + fn with_blendmode(&self, blend_mode: BlendMode, f: F) { + let canvas = self.canvas; + if blend_mode != BlendMode::Normal { + let mut paint = SkPaint::default(); + paint.set_blend_mode(blend_mode.into()); + canvas.save_layer(&SaveLayerRec::default().paint(&paint)); + f(); + canvas.restore(); + } else { + f(); + } + } + + /// Helper method to apply clipping to a region with optional corner radius + fn with_clip(&self, shape: &PainterShape, f: F) { + let canvas = self.canvas; + canvas.save(); + + // Try to use the most efficient clipping method based on shape type + if let Some(rect) = shape.rect_shape { + // Simple rectangle - use clip_rect (fastest) + canvas.clip_rect(rect, None, true); + } else if let Some(rrect) = &shape.rrect { + // Rounded rectangle - use clip_rrect (faster than path) + canvas.clip_rrect(rrect, None, true); + } else { + // Complex shape - fall back to path clipping + canvas.clip_path(&shape.to_path(), None, true); + } + + f(); + canvas.restore(); + } + + /// Wrap a closure `f` in a layer that applies a Gaussian blur to everything drawn inside. + fn with_layer_blur(&self, radius: f32, f: F) { + let canvas = self.canvas; + let image_filter = skia_safe::image_filters::blur((radius, radius), None, None, None); + let mut paint = SkPaint::default(); + paint.set_image_filter(image_filter); + canvas.save_layer(&SaveLayerRec::default().paint(&paint)); + f(); + canvas.restore(); + } + + /// Draw a drop shadow behind the content using a shape. + fn draw_shadow(&self, shape: &PainterShape, shadow: &FeDropShadow) { + let canvas = self.canvas; + let Color(r, g, b, a) = shadow.color; + let color = skia_safe::Color::from_argb(a, r, g, b); + + // Create drop shadow filter + let image_filter = skia_safe::image_filters::drop_shadow( + (shadow.dx, shadow.dy), // offset as tuple + (shadow.blur, shadow.blur), // sigma as tuple + color, // color + None, // color_space + None, // input + None, // crop_rect + ); + + // Create paint with the drop shadow filter + let mut shadow_paint = SkPaint::default(); + shadow_paint.set_image_filter(image_filter); + shadow_paint.set_anti_alias(true); + + // Draw the shadow using the shape's path + canvas.draw_path(&shape.to_path(), &shadow_paint); + } + + /// Draw a backdrop blur: blur what's behind the shape. + fn draw_backdrop_blur(&self, shape: &PainterShape, blur: &FeBackdropBlur) { + let canvas = self.canvas; + // 1) Build a Gaussian‐blur filter for the backdrop + let Some(image_filter) = + skia_safe::image_filters::blur((blur.radius, blur.radius), None, None, None) + else { + return; + }; + + // 2) Clip to the shape + canvas.save(); + canvas.clip_path(&shape.to_path(), None, true); + + // 3) Use a SaveLayerRec with a backdrop filter so that everything behind is blurred + let layer_rec = SaveLayerRec::default().backdrop(&image_filter); + canvas.save_layer(&layer_rec); + + // We don't draw any content here—just pushing and popping the layer + canvas.restore(); // pop the SaveLayer + canvas.restore(); // pop the clip + } + + /// Determine the transformation matrix for an [`ImagePaint`]. + /// + /// If the paint specifies a [`BoxFit`] other than `None`, the box-fit + /// transform is used. Otherwise, the paint's own transform is applied. + fn image_paint_matrix( + &self, + paint: &ImagePaint, + image_size: (f32, f32), + container_size: (f32, f32), + ) -> [[f32; 3]; 2] { + match paint.fit { + BoxFit::None => paint.transform.matrix, + _ => { + paint + .fit + .calculate_transform(image_size, container_size) + .matrix + } + } + } + + /// Draw fill for a shape using given paint. + fn draw_fill(&self, shape: &PainterShape, fill: &Paint) { + let canvas = self.canvas; + let (fill_paint, image, image_params) = match fill { + Paint::Image(image_paint) => { + let images = self.images.borrow(); + if let Some(image) = + images.get_by_size(&image_paint._ref, shape.rect.width(), shape.rect.height()) + { + let mut paint = SkPaint::default(); + paint.set_anti_alias(true); + (paint, Some(image.clone()), Some(image_paint.clone())) + } else { + // Image not ready - skip fill + return; + } + } + _ => ( + cvt::sk_paint(fill, 1.0, (shape.rect.width(), shape.rect.height())), + None, + None, + ), + }; + + if let (Some(image), Some(img_paint)) = (image, image_params) { + // For image fills, clip to the shape and apply transforms + canvas.save(); + canvas.clip_path(&shape.to_path(), None, true); + + // Apply either the fit transform or the paint's custom transform + let m = self.image_paint_matrix( + &img_paint, + (image.width() as f32, image.height() as f32), + (shape.rect.width(), shape.rect.height()), + ); + canvas.concat(&cvt::sk_matrix(m)); + + canvas.draw_image_rect( + &image, + None, + skia_safe::Rect::from_xywh(0.0, 0.0, image.width() as f32, image.height() as f32), + &fill_paint, + ); + canvas.restore(); + } else { + // For regular fills, draw the shape directly + canvas.draw_path(&shape.to_path(), &fill_paint); + } + } + + /// Draw stroke for a shape using given paint. + fn draw_stroke( + &self, + shape: &PainterShape, + stroke: &Paint, + stroke_width: f32, + stroke_align: StrokeAlign, + stroke_dash_array: Option<&Vec>, + ) { + if stroke_width <= 0.0 { + return; + } + + // Generate the stroke geometry + let stroke_path = stroke_geometry( + &shape.to_path(), + stroke_width, + stroke_align, + stroke_dash_array, + ); + + self.draw_stroke_path(shape, stroke, &stroke_path); + } + + /// Draw stroke for a shape using a precomputed stroke path. + fn draw_stroke_path( + &self, + shape: &PainterShape, + stroke: &Paint, + stroke_path: &skia_safe::Path, + ) { + let canvas = self.canvas; + + // Draw the stroke using the generated geometry + match stroke { + Paint::Image(image_paint) => { + let images = self.images.borrow(); + if let Some(image) = + images.get_by_size(&image_paint._ref, shape.rect.width(), shape.rect.height()) + { + let mut paint = SkPaint::default(); + paint.set_anti_alias(true); + + // For image strokes, clip and apply transforms + canvas.save(); + canvas.clip_path(&stroke_path, None, true); + + let m = self.image_paint_matrix( + image_paint, + (image.width() as f32, image.height() as f32), + (shape.rect.width(), shape.rect.height()), + ); + canvas.concat(&cvt::sk_matrix(m)); + + canvas.draw_image_rect( + &image, + None, + skia_safe::Rect::from_xywh( + 0.0, + 0.0, + image.width() as f32, + image.height() as f32, + ), + &paint, + ); + canvas.restore(); + } + } + _ => { + let paint = cvt::sk_paint(stroke, 1.0, (shape.rect.width(), shape.rect.height())); + canvas.draw_path(&stroke_path, &paint); + } + } + } + + /// Shared utility to handle effect drawing for shapes + fn draw_shape_with_effect( + &self, + effect: Option<&FilterEffect>, + shape: &PainterShape, + draw_content: F, + ) { + match effect { + Some(FilterEffect::DropShadow(shadow)) => { + self.draw_shadow(shape, shadow); + draw_content(); + } + Some(FilterEffect::BackdropBlur(blur)) => { + self.draw_backdrop_blur(shape, blur); + draw_content(); + } + Some(FilterEffect::GaussianBlur(blur)) => { + self.with_layer_blur(blur.radius, draw_content); + } + None => { + draw_content(); + } + } + } + + // ============================ + // === Node Drawing Methods === + // ============================ + + /// Draw a RectangleNode, respecting its transform, effect, fill, stroke, blend mode, opacity + fn draw_rect_node(&self, node: &RectangleNode) { + self.with_transform(&node.transform.matrix, || { + let shape = build_shape(&IntrinsicSizeNode::Rectangle(node.clone())); + self.draw_shape_with_effect(node.effect.as_ref(), &shape, || { + self.with_opacity(node.opacity, || { + self.with_blendmode(node.blend_mode, || { + self.draw_fill(&shape, &node.fill); + self.draw_stroke( + &shape, + &node.stroke, + node.stroke_width, + node.stroke_align, + node.stroke_dash_array.as_ref(), + ); + }); + }); + }); + }); + } + + /// Draw an ImageNode, respecting transform, effect, rounded corners, blend mode, opacity + fn draw_image_node(&self, node: &ImageNode) -> bool { + self.with_transform(&node.transform.matrix, || { + let shape = build_shape(&IntrinsicSizeNode::Image(node.clone())); + + self.draw_shape_with_effect(node.effect.as_ref(), &shape, || { + self.with_opacity(node.opacity, || { + self.with_blendmode(node.blend_mode, || { + // convert the image itself to a paint + let image_paint = Paint::Image(ImagePaint { + _ref: node._ref.clone(), + opacity: node.opacity, + transform: AffineTransform::identity(), + fit: math2::box_fit::BoxFit::Cover, + }); + + self.draw_fill(&shape, &image_paint); + self.draw_stroke( + &shape, + &node.stroke, + node.stroke_width, + node.stroke_align, + node.stroke_dash_array.as_ref(), + ); + }); + }); + }); + }); + true + } + + /// Draw an EllipseNode + fn draw_ellipse_node(&self, node: &EllipseNode) { + self.with_transform(&node.transform.matrix, || { + let shape = build_shape(&IntrinsicSizeNode::Ellipse(node.clone())); + self.draw_shape_with_effect(node.effect.as_ref(), &shape, || { + self.with_opacity(node.opacity, || { + self.with_blendmode(node.blend_mode, || { + self.draw_fill(&shape, &node.fill); + self.draw_stroke( + &shape, + &node.stroke, + node.stroke_width, + node.stroke_align, + node.stroke_dash_array.as_ref(), + ); + }); + }); + }); + }); + } + + /// Draw a LineNode + fn draw_line_node(&self, node: &LineNode) { + self.with_transform(&node.transform.matrix, || { + let shape = build_shape(&IntrinsicSizeNode::Line(node.clone())); + + self.with_opacity(node.opacity, || { + self.with_blendmode(node.blend_mode, || { + let paint = cvt::sk_paint(&node.stroke, node.opacity, (node.size.width, 0.0)); + let stroke_path = stroke_geometry( + &shape.to_path(), + node.stroke_width, + node.stroke_align, + node.stroke_dash_array.as_ref(), + ); + self.canvas.draw_path(&stroke_path, &paint); + }); + }); + }); + } + + /// Draw a PathNode (SVG path data) + fn draw_path_node(&self, node: &PathNode) { + self.with_transform(&node.transform.matrix, || { + let path = skia_safe::path::Path::from_svg(&node.data).expect("invalid SVG path"); + let shape = PainterShape::from_path(path.clone()); + self.draw_shape_with_effect(node.effect.as_ref(), &shape, || { + self.with_opacity(node.opacity, || { + self.with_blendmode(node.blend_mode, || { + self.draw_fill(&shape, &node.fill); + self.draw_stroke( + &shape, + &node.stroke, + node.stroke_width, + node.stroke_align, + node.stroke_dash_array.as_ref(), + ); + }); + }); + }); + }); + } + + /// Draw a PolygonNode (arbitrary polygon with optional corner radius) + fn draw_polygon_node(&self, node: &PolygonNode) { + self.with_transform(&node.transform.matrix, || { + let path = node.to_path(); + let shape = PainterShape::from_path(path.clone()); + self.draw_shape_with_effect(node.effect.as_ref(), &shape, || { + self.with_opacity(node.opacity, || { + self.with_blendmode(node.blend_mode, || { + self.draw_fill(&shape, &node.fill); + self.draw_stroke( + &shape, + &node.stroke, + node.stroke_width, + node.stroke_align, + node.stroke_dash_array.as_ref(), + ); + }); + }); + }); + }); + } + + /// Draw a RegularPolygonNode by converting to a PolygonNode + fn draw_regular_polygon_node(&self, node: &RegularPolygonNode) { + let polygon = node.to_polygon(); + self.draw_polygon_node(&polygon); + } + + /// Draw a RegularStarPolygonNode by converting to a PolygonNode + fn draw_regular_star_polygon_node(&self, node: &RegularStarPolygonNode) { + let polygon = node.to_polygon(); + self.draw_polygon_node(&polygon); + } + + fn draw_text_span( + &self, + text: &str, + size: &Size, + fill: &Paint, + text_align: &TextAlign, + _text_align_vertical: &TextAlignVertical, + text_style: &TextStyle, + ) { + // Prepare paint for fill + let fill_paint = cvt::sk_paint(&fill, 1.0, (size.width, size.height)); + + // Build paragraph style + let mut paragraph_style = textlayout::ParagraphStyle::new(); + paragraph_style.set_text_direction(textlayout::TextDirection::LTR); + paragraph_style.set_text_align(text_align.clone().into()); + + let fonts = self.fonts.borrow(); + let mut para_builder = + textlayout::ParagraphBuilder::new(¶graph_style, &fonts.font_collection()); + + // Build text style + let mut ts = make_textstyle(&text_style); + ts.set_foreground_paint(&fill_paint); + + para_builder.push_style(&ts); + // Apply text transform before adding text + let transformed_text = + crate::text::text_transform::transform_text(&text, text_style.text_transform); + para_builder.add_text(&transformed_text); + let mut paragraph = para_builder.build(); + para_builder.pop(); + paragraph.layout(size.width); + + paragraph.paint(self.canvas, Point::new(0.0, 0.0)); + } + + /// Draw a TextSpanNode (simple text block) + fn draw_text_span_node(&self, node: &TextSpanNode) { + self.with_transform(&node.transform.matrix, || { + self.with_opacity(node.opacity, || { + self.with_blendmode(node.blend_mode, || { + self.draw_text_span( + &node.text, + &node.size, + &node.fill, + &node.text_align, + &node.text_align_vertical, + &node.text_style, + ); + }); + }); + }); + + // // Prepare paint for fill + // let mut fill_paint = cvt::sk_paint( + // &node.fill, + // node.opacity, + // (node.size.width, node.size.height), + // ); + // fill_paint.set_blend_mode(node.blend_mode.into()); + + // // Build paragraph style + // let mut paragraph_style = ParagraphStyle::new(); + // paragraph_style.set_text_direction(TextDirection::LTR); + // paragraph_style.set_text_align(node.text_align.into()); + + // let fonts = self.fonts.borrow(); + // let mut para_builder = ParagraphBuilder::new(¶graph_style, &fonts.font_collection()); + + // // Build text style + // let mut ts = make_textstyle(&node.text_style); + // ts.set_foreground_paint(&fill_paint); + + // para_builder.push_style(&ts); + // // Apply text transform before adding text + // let transformed_text = + // crate::text::text_transform::transform_text(&node.text, node.text_style.text_transform); + // para_builder.add_text(&transformed_text); + // let mut paragraph = para_builder.build(); + // para_builder.pop(); + // paragraph.layout(node.size.width); + + // self.with_transform(&node.transform.matrix, || { + // paragraph.paint(self.canvas, Point::new(0.0, 0.0)); + // }); + } + + /// Draw a ContainerNode (background + stroke + children) + fn draw_container_node_recursively( + &self, + node: &ContainerNode, + repository: &NodeRepository, + cache: &GeometryCache, + ) { + self.with_transform(&node.transform.matrix, || { + self.with_opacity(node.opacity, || { + let shape = build_shape(&IntrinsicSizeNode::Container(node.clone())); + + // Draw effects first (if any) - these won't be clipped + self.draw_shape_with_effect(node.effect.as_ref(), &shape, || { + self.with_blendmode(node.blend_mode, || { + self.draw_fill(&shape, &node.fill); + if let Some(stroke) = &node.stroke { + self.draw_stroke( + &shape, + stroke, + node.stroke_width, + node.stroke_align, + node.stroke_dash_array.as_ref(), + ); + } + }); + }); + + // Draw children with clipping if enabled + if node.clip { + self.with_clip(&shape, || { + for child_id in &node.children { + if let Some(child) = repository.get(child_id) { + self.draw_node_recursively(child, repository, cache); + } + } + }); + } else { + // Draw children without clipping + for child_id in &node.children { + if let Some(child) = repository.get(child_id) { + self.draw_node_recursively(child, repository, cache); + } + } + } + }); + }); + } + + fn draw_error_node(&self, node: &ErrorNode) { + self.with_transform(&node.transform.matrix, || { + let shape = build_shape(&IntrinsicSizeNode::Error(node.clone())); + + // Create a red fill paint + let fill = Paint::Solid(SolidPaint { + color: Color(255, 0, 0, 51), // Semi-transparent red + opacity: 1.0, + }); + let stroke = Paint::Solid(SolidPaint { + color: Color(255, 0, 0, 255), // Solid red + opacity: 1.0, + }); + + self.with_opacity(node.opacity, || { + self.draw_fill(&shape, &fill); + self.draw_stroke(&shape, &stroke, 1.0, StrokeAlign::Inside, None); + }); + }); + } + + /// Draw a GroupNode: no shape of its own, only children, but apply transform + opacity + fn draw_group_node_recursively( + &self, + node: &GroupNode, + repository: &NodeRepository, + cache: &GeometryCache, + ) { + self.with_transform(&node.transform.matrix, || { + self.with_opacity(node.opacity, || { + for child_id in &node.children { + if let Some(child) = repository.get(child_id) { + self.draw_node_recursively(child, repository, cache); + } + } + }); + }); + } + + fn draw_boolean_operation_node_recursively( + &self, + node: &BooleanPathOperationNode, + repository: &NodeRepository, + cache: &GeometryCache, + ) { + self.with_transform(&node.transform.matrix, || { + if let Some(shape) = boolean_operation_shape(node, repository, cache) { + self.draw_shape_with_effect(node.effect.as_ref(), &shape, || { + self.with_opacity(node.opacity, || { + self.with_blendmode(node.blend_mode, || { + self.draw_fill(&shape, &node.fill); + if let Some(stroke) = &node.stroke { + self.draw_stroke( + &shape, + stroke, + node.stroke_width, + node.stroke_align, + node.stroke_dash_array.as_ref(), + ); + } + }); + }); + }); + } else { + for child_id in &node.children { + if let Some(child) = repository.get(child_id) { + self.draw_node_recursively(child, repository, cache); + } + } + } + }); + } + + pub fn draw_node(&self, node: &LeafNode) { + match node { + LeafNode::Error(n) => self.draw_error_node(n), + LeafNode::Rectangle(n) => self.draw_rect_node(n), + LeafNode::Ellipse(n) => self.draw_ellipse_node(n), + LeafNode::Polygon(n) => self.draw_polygon_node(n), + LeafNode::RegularPolygon(n) => self.draw_regular_polygon_node(n), + LeafNode::TextSpan(n) => self.draw_text_span_node(n), + LeafNode::Line(n) => self.draw_line_node(n), + LeafNode::Image(n) => { + self.draw_image_node(n); + } + LeafNode::Path(n) => self.draw_path_node(n), + LeafNode::RegularStarPolygon(n) => self.draw_regular_star_polygon_node(n), + } + } + + /// Dispatch to the correct node‐type draw method + pub fn draw_node_recursively( + &self, + node: &Node, + repository: &NodeRepository, + cache: &GeometryCache, + ) { + match node { + Node::Error(n) => self.draw_error_node(n), + Node::Group(n) => self.draw_group_node_recursively(n, repository, cache), + Node::Container(n) => self.draw_container_node_recursively(n, repository, cache), + Node::Rectangle(n) => self.draw_rect_node(n), + Node::Ellipse(n) => self.draw_ellipse_node(n), + Node::Polygon(n) => self.draw_polygon_node(n), + Node::RegularPolygon(n) => self.draw_regular_polygon_node(n), + Node::TextSpan(n) => self.draw_text_span_node(n), + Node::Line(n) => self.draw_line_node(n), + Node::Image(n) => { + self.draw_image_node(n); + } + Node::Path(n) => self.draw_path_node(n), + Node::BooleanOperation(n) => { + self.draw_boolean_operation_node_recursively(n, repository, cache) + } + Node::RegularStarPolygon(n) => self.draw_regular_star_polygon_node(n), + } + } + + /// Draw a single [`PainterPictureLayer`]. + pub fn draw_layer(&self, layer: &PainterPictureLayer) { + match layer { + PainterPictureLayer::Shape(shape_layer) => { + self.with_transform(&shape_layer.base.transform.matrix, || { + let shape = &shape_layer.base.shape; + let effect = shape_layer.base.effects.first(); + let clip_path = &shape_layer.base.clip_path; + let draw_content = || { + self.with_opacity(shape_layer.base.opacity, || { + for fill in &shape_layer.base.fills { + self.draw_fill(shape, fill); + } + for stroke in &shape_layer.base.strokes { + if let Some(path) = &shape_layer.base.stroke_path { + self.draw_stroke_path(shape, stroke, path); + } + } + }); + }; + if let Some(clip) = clip_path { + self.canvas.save(); + self.canvas.clip_path(clip, None, true); + self.draw_shape_with_effect(effect, shape, draw_content); + self.canvas.restore(); + } else { + self.draw_shape_with_effect(effect, shape, draw_content); + } + }); + } + PainterPictureLayer::Text(text_layer) => { + self.with_transform(&text_layer.base.transform.matrix, || { + let shape = &text_layer.base.shape; + let effect = text_layer.base.effects.first(); + let clip_path = &text_layer.base.clip_path; + let draw_content = || { + self.with_opacity(text_layer.base.opacity, || { + self.draw_text_span( + &text_layer.text, + &Size { + width: shape.rect.width(), + height: shape.rect.height(), + }, + match text_layer.base.fills.first() { + Some(f) => f, + None => return, + }, + &text_layer.text_align, + &text_layer.text_align_vertical, + &text_layer.text_style, + ); + }); + }; + if let Some(clip) = clip_path { + self.canvas.save(); + self.canvas.clip_path(clip, None, true); + self.draw_shape_with_effect(effect, shape, draw_content); + self.canvas.restore(); + } else { + self.draw_shape_with_effect(effect, shape, draw_content); + } + }); + } + } + } + + /// Draw all layers in a [`LayerList`]. + pub fn draw_layer_list(&self, list: &LayerList) { + for layer in &list.layers { + self.draw_layer(layer); + } + } +} + +fn make_textstyle(text_style: &TextStyle) -> skia_safe::textlayout::TextStyle { + let mut ts = skia_safe::textlayout::TextStyle::new(); + ts.set_font_size(text_style.font_size); + if let Some(letter_spacing) = text_style.letter_spacing { + ts.set_letter_spacing(letter_spacing); + } + if let Some(line_height) = text_style.line_height { + ts.set_height(line_height); + } + let mut decor = skia_safe::textlayout::Decoration::default(); + decor.ty = text_style.text_decoration.into(); + ts.set_decoration(&decor); + ts.set_font_families(&[&text_style.font_family]); + let font_style = skia_safe::FontStyle::new( + skia_safe::font_style::Weight::from(text_style.font_weight.value() as i32), + skia_safe::font_style::Width::NORMAL, + if text_style.italic { + skia_safe::font_style::Slant::Italic + } else { + skia_safe::font_style::Slant::Upright + }, + ); + ts.set_font_style(font_style); + ts +} diff --git a/crates/grida-canvas/src/repository.rs b/crates/grida-canvas/src/repository.rs new file mode 100644 index 0000000000..596b828dd3 --- /dev/null +++ b/crates/grida-canvas/src/repository.rs @@ -0,0 +1,237 @@ +use skia_safe::{ + FontMgr, Image, + textlayout::{FontCollection, TypefaceFontProvider}, +}; + +use crate::mipmap::{ImageMipmaps, MipmapConfig}; +use std::collections::HashMap; + +/// Generic repository trait for storing resources keyed by an identifier. +pub trait ResourceRepository { + type Id; + + /// Insert a resource with an identifier. + fn insert(&mut self, id: Self::Id, item: T); + + /// Get a reference to a resource by id. + fn get(&self, id: &Self::Id) -> Option<&T>; + + /// Get a mutable reference to a resource by id. + fn get_mut(&mut self, id: &Self::Id) -> Option<&mut T>; + + /// Remove a resource, returning it if present. + fn remove(&mut self, id: &Self::Id) -> Option; + + /// Iterator over the resources. + type Iter<'a>: Iterator + where + Self: 'a, + T: 'a; + + fn iter(&self) -> Self::Iter<'_>; + + /// Number of stored resources. + fn len(&self) -> usize; + + /// Whether repository is empty. + fn is_empty(&self) -> bool; +} + +/// A repository for managing images with automatic ID indexing. +#[derive(Debug, Clone)] +pub struct ImageRepository { + /// The map of all images indexed by their source URLs + images: HashMap, + config: MipmapConfig, +} + +impl ImageRepository { + /// Creates a new empty image repository + pub fn new() -> Self { + Self { + images: HashMap::new(), + config: MipmapConfig::default(), + } + } + + /// Creates a repository with custom mipmap configuration + pub fn with_config(config: MipmapConfig) -> Self { + Self { + images: HashMap::new(), + config, + } + } + + /// Adds an image to the repository + pub fn insert(&mut self, src: String, image: Image) { + let set = ImageMipmaps::from_image(image, &self.config); + self.images.insert(src, set); + } + + /// Gets a reference to an image by its source URL and desired size + pub fn get_by_size(&self, src: &str, width: f32, height: f32) -> Option<&Image> { + self.images + .get(src) + .and_then(|set| set.best_for_size(width, height)) + } + + /// Removes an image from the repository by its source URL + pub fn remove(&mut self, src: &str) -> Option { + self.images.remove(src) + } +} + +impl ResourceRepository for ImageRepository { + type Id = String; + type Iter<'a> = std::collections::hash_map::Iter<'a, String, ImageMipmaps>; + + fn insert(&mut self, id: Self::Id, item: ImageMipmaps) { + self.images.insert(id, item); + } + + fn get(&self, id: &Self::Id) -> Option<&ImageMipmaps> { + self.images.get(id) + } + + fn get_mut(&mut self, id: &Self::Id) -> Option<&mut ImageMipmaps> { + self.images.get_mut(id) + } + + fn remove(&mut self, id: &Self::Id) -> Option { + self.images.remove(id) + } + + fn iter(&self) -> Self::Iter<'_> { + self.images.iter() + } + + fn len(&self) -> usize { + self.images.len() + } + + fn is_empty(&self) -> bool { + self.images.is_empty() + } +} + +/// A repository for managing fonts. +pub struct FontRepository { + provider: TypefaceFontProvider, + fonts: HashMap>>, +} + +impl FontRepository { + pub fn new() -> Self { + Self { + provider: TypefaceFontProvider::new(), + fonts: HashMap::new(), + } + } + + pub fn insert(&mut self, family: String, bytes: Vec) { + let family_fonts = self.fonts.entry(family.clone()).or_insert_with(Vec::new); + + if let Some(tf) = FontMgr::new().new_from_data(&bytes, None) { + self.provider.register_typeface(tf, Some(family.as_str())); + } + + family_fonts.push(bytes); + } + + pub fn add(&mut self, bytes: &[u8], family: &str) { + let family_fonts = self + .fonts + .entry(family.to_string()) + .or_insert_with(Vec::new); + + if let Some(tf) = FontMgr::new().new_from_data(bytes, None) { + self.provider.register_typeface(tf, Some(family)); + } + + family_fonts.push(bytes.to_vec()); + } + + pub fn font_collection(&self) -> FontCollection { + let mut collection = FontCollection::new(); + collection.set_asset_font_manager(Some(self.provider.clone().into())); + collection + } + + pub fn family_count(&self) -> usize { + self.fonts.len() + } + + pub fn total_font_count(&self) -> usize { + self.fonts.values().map(|fonts| fonts.len()).sum() + } + + pub fn get_family_fonts(&self, family: &str) -> Option<&Vec>> { + self.fonts.get(family) + } +} + +impl ResourceRepository>> for FontRepository { + type Id = String; + type Iter<'a> = std::collections::hash_map::Iter<'a, String, Vec>>; + + fn insert(&mut self, id: Self::Id, item: Vec>) { + for font_data in &item { + if let Some(tf) = FontMgr::new().new_from_data(font_data, None) { + self.provider.register_typeface(tf, Some(id.as_str())); + } + } + self.fonts.insert(id, item); + } + + fn get(&self, id: &Self::Id) -> Option<&Vec>> { + self.fonts.get(id) + } + + fn get_mut(&mut self, id: &Self::Id) -> Option<&mut Vec>> { + self.fonts.get_mut(id) + } + + fn remove(&mut self, id: &Self::Id) -> Option>> { + self.fonts.remove(id) + } + + fn iter(&self) -> Self::Iter<'_> { + self.fonts.iter() + } + + fn len(&self) -> usize { + self.fonts.len() + } + + fn is_empty(&self) -> bool { + self.fonts.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use skia_safe::surfaces; + + #[test] + fn image_repository_basic() { + let mut repo = ImageRepository::new(); + let mut surface = surfaces::raster_n32_premul((1, 1)).expect("failed to create surface"); + let image = surface.image_snapshot(); + repo.insert("img".to_string(), image.clone()); + assert!(repo.get_by_size("img", 1.0, 1.0).is_some()); + assert_eq!(repo.len(), 1); + repo.remove("img"); + assert!(repo.is_empty()); + } + + #[test] + fn font_repository_basic() { + let mut repo = FontRepository::new(); + repo.insert("f1".to_string(), vec![0u8; 4]); + assert!(repo.get(&"f1".to_string()).is_some()); + assert_eq!(repo.len(), 1); + repo.remove(&"f1".to_string()); + assert!(repo.is_empty()); + } +} diff --git a/crates/grida-canvas/src/resource_loader.rs b/crates/grida-canvas/src/resource_loader.rs new file mode 100644 index 0000000000..09bc57ec2f --- /dev/null +++ b/crates/grida-canvas/src/resource_loader.rs @@ -0,0 +1,13 @@ +use async_trait::async_trait; + +/// Generic asynchronous resource loader trait. +#[async_trait] +pub trait ResourceLoader { + type Output; + + /// Load a resource identified by `key` from `src`. + async fn load(&mut self, key: &str, src: &str) -> Option; + + /// Unload or remove a cached resource identified by `key`. + async fn unload(&mut self, key: &str); +} diff --git a/crates/grida-canvas/src/runtime/camera.rs b/crates/grida-canvas/src/runtime/camera.rs new file mode 100644 index 0000000000..4972c9e2c4 --- /dev/null +++ b/crates/grida-canvas/src/runtime/camera.rs @@ -0,0 +1,155 @@ +use crate::node::schema::Size; +use math2::{quantize, rect, rect::Rectangle, transform::AffineTransform, vector2}; + +/// A 2D camera that defines how world-space content is projected onto the screen. +/// +/// The camera is defined by a transform and a logical viewport size. The transform represents +/// the camera's position, rotation, and zoom **in world space**, where the translation component +/// specifies the world-space point that should appear at the **center of the viewport**. +/// +/// This model is commonly used in design tools and 2D canvas systems where zooming and panning +/// behavior is centered on the screen. +/// +/// # Fields +/// - `transform`: The camera's transform in world space. Its translation corresponds to the +/// world coordinate that appears at the center of the screen. +/// - `size`: The logical size of the viewport in pixels (not affected by zoom). +/// +/// This shifts the camera's center to the screen center and transforms the scene accordingly. +#[derive(Debug, Clone)] +pub struct Camera2D { + pub transform: AffineTransform, + pub size: Size, + /// Minimum allowed zoom value + pub min_zoom: f32, + /// Maximum allowed zoom value + pub max_zoom: f32, +} + +impl Camera2D { + const POSITION_STEP_PX: f32 = 5.0; + const ZOOM_STEP: f32 = 0.01; + /// Default maximum zoom level + pub const DEFAULT_MAX_ZOOM: f32 = 256.0; + /// Default minimum zoom level + pub const DEFAULT_MIN_ZOOM: f32 = 0.02; + + /// Create with identity transform + no zoom (1:1). + /// Create with identity transform + no zoom (1:1) using default zoom limits. + pub fn new(viewport_size: Size) -> Self { + Self::with_zoom_limits( + viewport_size, + Self::DEFAULT_MIN_ZOOM, + Self::DEFAULT_MAX_ZOOM, + ) + } + + /// Create a camera specifying custom zoom limits. + pub fn with_zoom_limits(viewport_size: Size, min_zoom: f32, max_zoom: f32) -> Self { + let mut c = Self { + transform: AffineTransform::identity(), + size: viewport_size, + min_zoom, + max_zoom, + }; + c.set_zoom(1.0); + c + } + + /// Pan camera by (tx, ty) in world units. + pub fn translate(&mut self, tx: f32, ty: f32) { + self.transform.translate(tx, ty); + } + + /// Jump camera center to (x, y) in world units. + pub fn set_position(&mut self, x: f32, y: f32) { + self.transform.set_translation(x, y); + } + + /// Set zoom factor (1 = 100%). Preserves rotation & translation. + pub fn set_zoom(&mut self, zoom: f32) { + let zoom = zoom.clamp(self.min_zoom, self.max_zoom); + let tx = self.transform.x(); + let ty = self.transform.y(); + let (s, c) = self.transform.rotation().sin_cos(); + let scale = 1.0 / zoom; + self.transform.matrix = [[c * scale, -s * scale, tx], [s * scale, c * scale, ty]]; + } + + /// Sets the zoom while keeping the given screen-space point fixed. + pub fn set_zoom_at(&mut self, zoom: f32, screen_point: vector2::Vector2) { + let before = self.screen_to_canvas_point(screen_point); + self.set_zoom(zoom); + let after = self.screen_to_canvas_point(screen_point); + self.translate(before[0] - after[0], before[1] - after[1]); + } + + /// Get current zoom (1/scale). + pub fn get_zoom(&self) -> f32 { + 1.0 / self.transform.get_scale_x() + } + + /// Returns the camera transform quantized to the nearest visible pixel. + pub fn quantized_transform(&self) -> AffineTransform { + let zoom = self.get_zoom(); + let quant_zoom = quantize(zoom, Self::ZOOM_STEP); + + // translate world-space camera position into screen space using + // the quantized zoom factor. This ensures snapping occurs in + // pixel space regardless of the zoom level or rotation. + let angle = self.transform.rotation(); + let (sin, cos) = angle.sin_cos(); + + let tx = self.transform.x(); + let ty = self.transform.y(); + + let screen_tx = self.size.width * 0.5 - quant_zoom * (cos * tx + sin * ty); + let screen_ty = self.size.height * 0.5 + quant_zoom * (sin * tx - cos * ty); + + let quant_tx = quantize(screen_tx, Self::POSITION_STEP_PX); + let quant_ty = quantize(screen_ty, Self::POSITION_STEP_PX); + + AffineTransform { + matrix: [ + [cos * quant_zoom, -sin * quant_zoom, quant_tx], + [sin * quant_zoom, cos * quant_zoom, quant_ty], + ], + } + } + + /// View matrix = center-screen translation × inverse(world→camera). + pub fn view_matrix(&self) -> AffineTransform { + let inv = self + .transform + .clone() + .inverse() + .unwrap_or_else(AffineTransform::identity); + let mut t = AffineTransform::identity(); + t.translate(self.size.width * 0.5, self.size.height * 0.5); + t.compose(&inv) + } + + /// World‐space rect currently visible. + pub fn rect(&self) -> Rectangle { + let vp = Rectangle { + x: 0.0, + y: 0.0, + width: self.size.width, + height: self.size.height, + }; + let inv = self + .view_matrix() + .inverse() + .unwrap_or_else(AffineTransform::identity); + rect::transform(vp, &inv) + } + + /// Converts a screen-space point to canvas coordinates using the inverse view matrix. + pub fn screen_to_canvas_point(&self, screen: vector2::Vector2) -> vector2::Vector2 { + let inv = self + .view_matrix() + .inverse() + .unwrap_or_else(AffineTransform::identity); + vector2::transform(screen, &inv) + } +} diff --git a/crates/grida-canvas/src/runtime/input.rs b/crates/grida-canvas/src/runtime/input.rs new file mode 100644 index 0000000000..41ac6f109d --- /dev/null +++ b/crates/grida-canvas/src/runtime/input.rs @@ -0,0 +1,16 @@ +use math2::vector2::Vector2; + +/// Tracks pointer state for the application. +/// +/// Currently only stores the cursor position in screen space. +#[derive(Debug, Clone, Copy)] +pub struct InputState { + /// Cursor position in logical screen coordinates. + pub cursor: Vector2, +} + +impl Default for InputState { + fn default() -> Self { + Self { cursor: [0.0, 0.0] } + } +} diff --git a/crates/grida-canvas/src/runtime/mod.rs b/crates/grida-canvas/src/runtime/mod.rs new file mode 100644 index 0000000000..ff8347da7f --- /dev/null +++ b/crates/grida-canvas/src/runtime/mod.rs @@ -0,0 +1,3 @@ +pub mod camera; +pub mod input; +pub mod scene; diff --git a/crates/grida-canvas/src/runtime/scene.rs b/crates/grida-canvas/src/runtime/scene.rs new file mode 100644 index 0000000000..f477227dcf --- /dev/null +++ b/crates/grida-canvas/src/runtime/scene.rs @@ -0,0 +1,568 @@ +use crate::cache::tile::RegionTileInfo; +use crate::node::schema::*; +use crate::painter::layer::Layer; +use crate::painter::{Painter, cvt}; +use crate::{ + cache, + repository::{FontRepository, ImageRepository}, + runtime::camera::Camera2D, +}; + +use math2::{self, rect, region}; +use skia_safe::{ + Canvas, Image, Paint as SkPaint, Picture, PictureRecorder, Rect, Surface, surfaces, +}; +use std::cell::RefCell; +use std::rc::Rc; +use std::time::{Duration, Instant}; + +/// Callback type used to request a new frame. +pub type RafCallback = Box; + +/// Type alias for tile information in frame planning +pub type FramePlanTileInfo = RegionTileInfo; + +#[derive(Clone)] +pub struct FramePlan { + /// cached tile keys with blur information + pub tiles: Vec, + /// when true, the renderer should schedule another frame to recache tiles + pub should_repaint_all: bool, + /// regions with their intersecting indices + pub regions: Vec<(rect::Rectangle, Vec)>, + pub display_list_duration: Duration, + pub display_list_size_estimated: usize, +} + +#[derive(Clone)] +pub struct DrawResult { + pub painter_duration: Duration, + pub cache_picture_used: usize, + pub cache_picture_size: usize, + pub cache_geometry_size: usize, + pub tiles_total: usize, + pub tiles_used: usize, +} + +#[derive(Clone)] +pub struct RenderStats { + pub frame: FramePlan, + pub draw: DrawResult, + pub frame_duration: Duration, + pub flush_duration: Duration, + pub total_duration: Duration, +} + +/// Choice of GPU vs. raster backend +pub enum Backend { + GL(*mut Surface), + Raster(*mut Surface), +} + +impl Backend { + pub fn get_surface(&self) -> *mut Surface { + match self { + Backend::GL(ptr) | Backend::Raster(ptr) => *ptr, + } + } +} + +/// test rect in canvas space + +/// --------------------------------------------------------------------------- +/// Renderer: manages backend, DPI, camera, and iterates over scene children +/// --------------------------------------------------------------------------- +pub struct Renderer { + backend: Option, + scene: Option, + pub camera: Option, + prev_quantized_camera_transform: Option, + pub image_repository: Rc>, + pub font_repository: Rc>, + scene_cache: cache::scene::SceneCache, + raf_callback: Option, + raf_requested: bool, + pending_stats: Option, + debug_tiles: bool, +} + +impl Renderer { + pub fn new() -> Self { + let font_repository = FontRepository::new(); + let font_repository = Rc::new(RefCell::new(font_repository)); + let image_repository = ImageRepository::new(); + let image_repository = Rc::new(RefCell::new(image_repository)); + Self { + backend: None, + scene: None, + camera: None, + prev_quantized_camera_transform: None, + image_repository, + font_repository, + scene_cache: cache::scene::SceneCache::new(), + raf_callback: None, + raf_requested: false, + pending_stats: None, + debug_tiles: false, + } + } + + /// Access the cached scene data. + pub fn scene_cache(&self) -> &cache::scene::SceneCache { + &self.scene_cache + } + + pub fn init_raster(width: i32, height: i32) -> *mut Surface { + let surface = + surfaces::raster_n32_premul((width, height)).expect("Failed to create raster surface"); + Box::into_raw(Box::new(surface)) + } + + pub fn set_backend(&mut self, backend: Backend) { + self.backend = Some(backend); + } + + pub fn add_font(&mut self, family: &str, bytes: &[u8]) { + self.font_repository + .borrow_mut() + .insert(family.to_string(), bytes.to_vec()); + } + + /// Create an image from raw encoded bytes. + pub fn add_image(&self, src: String, bytes: &[u8]) { + let data = skia_safe::Data::new_copy(bytes); + if let Some(image) = Image::from_encoded(data) { + self.image_repository.borrow_mut().insert(src, image); + } + } + + /// Flush the queued frame if any and return the completed statistics. + pub fn flush(&mut self) -> Option { + // early exit when there is nothing queued to render + if self.pending_stats.is_none() { + return None; + } + + let start = Instant::now(); + if let Some(backend) = &self.backend { + let surface = unsafe { &mut *backend.get_surface() }; + if let Some(mut gr_context) = surface.recording_context() { + if let Some(mut direct_context) = gr_context.as_direct_context() { + direct_context.flush_and_submit(); + } + } + } + self.raf_requested = false; + + let flush_duration = start.elapsed(); + if let Some(mut stats) = self.pending_stats.take() { + stats.flush_duration = flush_duration; + stats.total_duration = stats.frame_duration + flush_duration; + Some(stats) + } else { + None + } + } + + /// Returns `true` if a frame has been queued but not yet flushed. + pub fn has_pending_frame(&self) -> bool { + self.pending_stats.is_some() + } + + /// Set a callback that will be invoked whenever the renderer wants to + /// schedule another frame. + pub fn set_raf_callback(&mut self, cb: F) + where + F: Fn() + 'static, + { + self.raf_callback = Some(Box::new(cb)); + } + + /// Enable or disable tile debug rendering. + pub fn set_debug_tiles(&mut self, debug: bool) { + self.debug_tiles = debug; + } + + /// Returns `true` if tile debug rendering is enabled. + pub fn debug_tiles(&self) -> bool { + self.debug_tiles + } + + /// Request the next animation frame if one has not already been queued. + pub fn request_animation_frame(&mut self) { + if !self.raf_requested { + if let Some(cb) = &self.raf_callback { + cb(); + self.raf_requested = true; + } + } + } + + pub fn free(&mut self) { + if let Some(backend) = self.backend.take() { + let surface = unsafe { Box::from_raw(backend.get_surface()) }; + if let Some(mut gr_context) = surface.recording_context() { + if let Some(mut direct_context) = gr_context.as_direct_context() { + direct_context.abandon(); + } + } + } + } + + /// Set the active camera. Returns `true` if the quantized transform changed. + pub fn set_camera(&mut self, camera: Camera2D) -> bool { + let quantized = camera.quantized_transform(); + let changed = match self.prev_quantized_camera_transform { + Some(prev) => prev != quantized, + None => true, + }; + if changed { + self.prev_quantized_camera_transform = Some(quantized); + } + let zoom = camera.get_zoom(); + self.scene_cache.tile.update_zoom(zoom); + self.camera = Some(camera); + changed + } + + /// Load a scene into the renderer. Caching will be performed lazily during + /// rendering based on the configured caching strategy. + pub fn load_scene(&mut self, scene: Scene) { + println!("load_scene: {:?}", scene.nodes.len()); + self.scene_cache.update_geometry(&scene); + self.scene_cache.update_layers(&scene); + self.scene = Some(scene); + } + + pub fn should_cache_tiles(&self) -> bool { + self.scene_cache.tile.should_cache_tiles() + } + + /// Render the currently loaded scene if any. and report the time it took. + pub fn queue(&mut self) -> Option { + let start = Instant::now(); + + if self.scene.is_none() { + return None; + } + + if let Some(scene_ptr) = self.scene.as_ref().map(|s| s as *const Scene) { + // SAFETY: the pointer is only used for the duration of this call + // and the scene is not mutated while borrowed. + let Some(backend) = self.backend.as_ref() else { + return None; + }; + let surface = unsafe { &mut *backend.get_surface() }; + let scene = unsafe { &*scene_ptr }; + let width = surface.width() as f32; + let height = surface.height() as f32; + let mut canvas = surface.canvas(); + let rect = self.camera.as_ref().map(|c| c.rect()); + + if self.scene_cache.tile.should_repaint_all() { + self.scene_cache.tile.clear(); + } + + let frame = self.frame( + rect.unwrap_or(rect::Rectangle::empty()), + self.camera.as_ref().map(|c| c.get_zoom()).unwrap_or(1.0), + ); + let paint = self.draw(&mut canvas, &frame, scene.background_color, width, height); + + let encode_duration = start.elapsed(); + + // update tile cache when zoom is stable + if self.should_cache_tiles() { + if let Some(camera) = &self.camera { + self.scene_cache + .update_tiles(camera, surface, width, height); + } + } + + let duration = start.elapsed(); + + let stats = RenderStats { + frame, + draw: paint, + frame_duration: encode_duration, + flush_duration: Duration::default(), + total_duration: duration, + }; + + self.pending_stats = Some(stats.clone()); + self.request_animation_frame(); + return Some(stats); + } + + return None; + } + + /// Clear the cached scene picture. + pub fn invalidate_cache(&mut self) { + self.scene_cache.invalidate(); + } + + fn with_recording( + &self, + bounds: &rect::Rectangle, + draw: impl FnOnce(&Painter), + ) -> Option { + let mut recorder = PictureRecorder::new(); + let sk_bounds = Rect::new( + bounds.x, + bounds.y, + bounds.x + bounds.width, + bounds.y + bounds.height, + ); + let canvas = recorder.begin_recording(sk_bounds, None); + let painter = Painter::new( + canvas, + self.font_repository.clone(), + self.image_repository.clone(), + ); + draw(&painter); + recorder.finish_recording_as_picture(None) + } + + fn with_recording_cached( + &mut self, + id: &NodeId, + draw: impl FnOnce(&Painter), + ) -> Option { + if let Some(pic) = self.scene_cache.picture.get_node_picture(id) { + return Some(pic.clone()); + } + + let Some(bounds) = self.scene_cache.geometry.get_render_bounds(&id) else { + return None; + }; + let pic = self.with_recording(&bounds, draw); + + if let Some(pic) = &pic { + self.scene_cache + .picture + .set_node_picture(id.clone(), pic.clone()); + } + pic + } + + /// Plan the frame for rendering. + /// Arguments: + /// - bounds: the bounding rect to be rendered (in world space) + /// - zoom: the current zoom level + fn frame(&mut self, bounds: rect::Rectangle, zoom: f32) -> FramePlan { + let __before_ll = Instant::now(); + let force_full_repaint = self.scene_cache.tile.needs_full_repaint(); + + // Get tiles for the region with blur information and sorting + let region_tiles = self.scene_cache.tile.get_region_tiles(&bounds, zoom); + let visible_tiles: Vec = region_tiles.tiles().to_vec(); + let tile_rects: Vec<_> = region_tiles.tile_rects().to_vec(); + + let region = if force_full_repaint { + vec![bounds] + } else { + region::difference(bounds, &tile_rects) + }; + + let mut regions: Vec<(rect::Rectangle, Vec)> = Vec::new(); + + for rect in region { + let mut indices = self.scene_cache.intersects(rect); + + // TODO: sort is expensive + indices.sort(); + + regions.push((rect, indices)); + } + + let ll_len = regions.iter().map(|(_, indices)| indices.len()).sum(); + + let __ll_duration = __before_ll.elapsed(); + + FramePlan { + tiles: visible_tiles, + should_repaint_all: self.scene_cache.tile.should_repaint_all() || force_full_repaint, + regions, + // indices_should_paint: intersections.clone(), + display_list_duration: __ll_duration, + display_list_size_estimated: ll_len, + } + } + + /// Draw the scene to the canvas. + /// - canvas: the canvas to render to + /// - plan: the frame plan + /// - width: the width of the canvas + /// - height: the height of the canvas + fn draw( + &mut self, + canvas: &Canvas, + plan: &FramePlan, + background_color: Option, + width: f32, + height: f32, + ) -> DrawResult { + let __before_paint = Instant::now(); + let mut cache_picture_used = 0; + + canvas.clear(skia_safe::Color::TRANSPARENT); + + // Paint background color first if present + if let Some(bg_color) = background_color { + let Color(r, g, b, a) = bg_color; + let color = skia_safe::Color::from_argb(a, r, g, b); + let mut paint = SkPaint::default(); + paint.set_color(color); + // Paint the entire canvas with the background color + canvas.draw_rect(Rect::new(0.0, 0.0, width, height), &paint); + } + + canvas.save(); + + // Apply camera transform if present + if let Some(camera) = &self.camera { + canvas.concat(&cvt::sk_matrix(camera.view_matrix().matrix)); + } + + // draw image cache tiles + for tk in plan.tiles.iter() { + let tile_at_zoom = self.scene_cache.tile.get_tile(&tk.key); + if let Some(tile_at_zoom) = tile_at_zoom { + let image = &tile_at_zoom.image; + let src = Rect::new(0.0, 0.0, image.width() as f32, image.height() as f32); + let dst = Rect::from_xywh( + tk.key.0 as f32, + tk.key.1 as f32, + tk.key.2 as f32, + tk.key.3 as f32, + ); + let mut paint = SkPaint::default(); + + // Apply adaptive blur filter when the tile was captured at a lower zoom level + // (lower resolution) than the current view + if tk.blur && tk.blur_radius > 0.0 { + let blur_filter = skia_safe::image_filters::blur( + (tk.blur_radius, tk.blur_radius), + None, + None, + None, + ); + paint.set_image_filter(blur_filter); + } + + canvas.draw_image_rect( + image, + Some((&src, skia_safe::canvas::SrcRectConstraint::Fast)), + dst, + &paint, + ); + } + } + + // draw picture regions + for (region, indices) in &plan.regions { + for idx in indices { + let layer = self.scene_cache.layers.layers[*idx].clone(); + let picture = self.with_recording_cached(&layer.id(), |painter| { + painter.draw_layer(&layer); + }); + + if let Some(pic) = picture { + // clip to region + canvas.save(); + canvas.clip_rect( + Rect::from_xywh(region.x, region.y, region.width, region.height), + None, + false, + ); + canvas.draw_picture(pic, None, None); + canvas.restore(); + cache_picture_used += 1; + } + } + } + + let __painter_duration = __before_paint.elapsed(); + + canvas.restore(); + + DrawResult { + painter_duration: __painter_duration, + cache_picture_used, + cache_picture_size: self.scene_cache.picture.len(), + cache_geometry_size: self.scene_cache.geometry.len(), + tiles_total: self.scene_cache.tile.tiles().len(), + tiles_used: plan.tiles.len(), + } + // + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::node::{factory::NodeFactory, repository::NodeRepository}; + use math2::transform::AffineTransform; + + #[test] + fn picture_recorded_with_layer_bounds() { + let nf = NodeFactory::new(); + let mut repo = NodeRepository::new(); + + let mut rect = nf.create_rectangle_node(); + rect.size = Size { + width: 50.0, + height: 40.0, + }; + let rect_id = rect.base.id.clone(); + repo.insert(Node::Rectangle(rect)); + + let scene = Scene { + id: "scene".into(), + name: "test".into(), + transform: AffineTransform::identity(), + children: vec![rect_id.clone()], + nodes: repo, + background_color: None, + }; + + let mut renderer = Renderer::new(); + let surface_ptr = Renderer::init_raster(100, 100); + renderer.set_backend(Backend::Raster(surface_ptr)); + renderer.load_scene(scene); + renderer.queue(); + + let bounds = renderer + .scene_cache + .geometry + .get_render_bounds(&rect_id) + .expect("bounds not found"); + let pic = renderer + .scene_cache + .picture + .get_node_picture(&rect_id) + .expect("picture not cached"); + + let cull = pic.cull_rect(); + assert_eq!(cull.left(), bounds.x); + assert_eq!(cull.top(), bounds.y); + assert_eq!(cull.width(), bounds.width); + assert_eq!(cull.height(), bounds.height); + + renderer.free(); + } + + #[test] + fn recording_cached_returns_none_without_bounds() { + let mut renderer = Renderer::new(); + let surface_ptr = Renderer::init_raster(50, 50); + renderer.set_backend(Backend::Raster(surface_ptr)); + + // no scene loaded so geometry cache is empty + let pic = renderer.with_recording_cached(&"missing".to_string(), |_| {}); + assert!(pic.is_none()); + + renderer.free(); + } +} diff --git a/crates/grida-canvas/src/text/mod.rs b/crates/grida-canvas/src/text/mod.rs new file mode 100644 index 0000000000..458dfb7ec4 --- /dev/null +++ b/crates/grida-canvas/src/text/mod.rs @@ -0,0 +1 @@ +pub mod text_transform; diff --git a/crates/grida-canvas/src/text/text_transform.rs b/crates/grida-canvas/src/text/text_transform.rs new file mode 100644 index 0000000000..096ce1d4da --- /dev/null +++ b/crates/grida-canvas/src/text/text_transform.rs @@ -0,0 +1,122 @@ +use crate::node::schema::TextTransform; + +/// Applies text transformation according to CSS text-transform property. +/// +/// # Arguments +/// +/// * `text` - The input text to transform +/// * `transform` - The transformation to apply +/// +/// # Returns +/// +/// The transformed text string +/// +/// # Examples +/// +/// ```ignore +/// use cg::schema::TextTransform; +/// use cg::text_transform::transform_text; +/// +/// let text = "Hello World"; +/// assert_eq!(transform_text(text, TextTransform::Uppercase), "HELLO WORLD"); +/// assert_eq!(transform_text(text, TextTransform::Lowercase), "hello world"); +/// assert_eq!(transform_text(text, TextTransform::Capitalize), "Hello World"); +/// assert_eq!(transform_text(text, TextTransform::None), "Hello World"); +/// ``` +pub fn transform_text(text: &str, transform: TextTransform) -> String { + match transform { + TextTransform::None => text.to_string(), + TextTransform::Uppercase => text.to_uppercase(), + TextTransform::Lowercase => text.to_lowercase(), + TextTransform::Capitalize => { + let mut result = String::with_capacity(text.len()); + let mut capitalize_next = true; + + for c in text.chars() { + if capitalize_next && c.is_alphabetic() { + result.push(c.to_uppercase().next().unwrap_or(c)); + capitalize_next = false; + } else { + result.push(c); + // Consider a word boundary to be any non-alphanumeric character + capitalize_next = !c.is_alphanumeric(); + } + } + result + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_none_transform() { + let text = "Hello World"; + assert_eq!(transform_text(text, TextTransform::None), text); + } + + #[test] + fn test_uppercase_transform() { + let text = "Hello World"; + assert_eq!( + transform_text(text, TextTransform::Uppercase), + "HELLO WORLD" + ); + } + + #[test] + fn test_lowercase_transform() { + let text = "Hello World"; + assert_eq!( + transform_text(text, TextTransform::Lowercase), + "hello world" + ); + } + + #[test] + fn test_capitalize_transform() { + let text = "hello world"; + assert_eq!( + transform_text(text, TextTransform::Capitalize), + "Hello World" + ); + + let text = "hello WORLD"; + assert_eq!( + transform_text(text, TextTransform::Capitalize), + "Hello WORLD" + ); + + let text = "hello world"; + assert_eq!( + transform_text(text, TextTransform::Capitalize), + "Hello World" + ); + + let text = "hello.world"; + assert_eq!( + transform_text(text, TextTransform::Capitalize), + "Hello.World" + ); + + let text = "hello.world.test"; + assert_eq!( + transform_text(text, TextTransform::Capitalize), + "Hello.World.Test" + ); + + let text = "hello-world"; + assert_eq!( + transform_text(text, TextTransform::Capitalize), + "Hello-World" + ); + + let text = "hello_world"; + assert_eq!( + transform_text(text, TextTransform::Capitalize), + "Hello_World" + ); + } +} diff --git a/crates/grida-canvas/src/webfont_helper.rs b/crates/grida-canvas/src/webfont_helper.rs new file mode 100644 index 0000000000..19b0b6542e --- /dev/null +++ b/crates/grida-canvas/src/webfont_helper.rs @@ -0,0 +1,169 @@ +#[cfg(not(target_arch = "wasm32"))] +use reqwest; + +use serde_json::Value; + +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct FontInfo { + pub family: String, + pub postscript_names: std::collections::HashSet, + pub styles: std::collections::HashSet, +} + +#[derive(Debug, Default)] +pub struct FontUsageStore { + fonts: std::collections::HashMap, +} + +impl FontUsageStore { + pub fn new() -> Self { + Self { + fonts: std::collections::HashMap::new(), + } + } + + pub fn register_font( + &mut self, + family: String, + postscript_name: Option, + style: Option, + ) { + let font_info = self.fonts.entry(family.clone()).or_insert(FontInfo { + family, + postscript_names: std::collections::HashSet::new(), + styles: std::collections::HashSet::new(), + }); + + if let Some(postscript) = postscript_name { + font_info.postscript_names.insert(postscript); + } + + if let Some(style) = style { + font_info.styles.insert(style); + } + } + + pub fn get_discovered_fonts(&self) -> Vec { + self.fonts.values().cloned().collect() + } + + pub fn clear(&mut self) { + self.fonts.clear(); + } +} + +#[derive(Debug, Clone)] +pub struct FontFileInfo { + pub family: String, + pub postscript_name: String, + pub style: String, + pub url: String, +} + +#[cfg(not(target_arch = "wasm32"))] +pub async fn load_webfonts_metadata() -> Result { + let url = "https://raw.githubusercontent.com/gridaco/google.fonts/refs/heads/main/metadata/webfonts.metadata.json"; + let response = reqwest::get(url) + .await + .map_err(|e| format!("Failed to fetch webfonts metadata: {}", e))?; + + let content = response + .text() + .await + .map_err(|e| format!("Failed to read webfonts metadata response: {}", e))?; + + serde_json::from_str(&content).map_err(|e| format!("Failed to parse webfonts metadata: {}", e)) +} + +#[cfg(target_arch = "wasm32")] +pub async fn load_webfonts_metadata() -> Result { + // Stub for wasm + Err("Webfonts metadata fetching not supported in wasm".into()) +} + +pub fn find_font_files(metadata: &Value, discovered_fonts: &[FontInfo]) -> Vec { + let mut font_files = Vec::new(); + + for font in discovered_fonts { + // Try to find the font family in metadata (case-insensitive) + let font_data = metadata.as_object().and_then(|obj| { + obj.iter() + .find(|(key, _)| key.to_lowercase() == font.family.to_lowercase()) + .map(|(_, value)| value) + }); + + if let Some(font_data) = font_data { + if let Some(postscript_names) = font_data + .get("post_script_names") + .and_then(|v| v.as_object()) + { + if let Some(files) = font_data.get("files").and_then(|v| v.as_object()) { + for postscript_name in &font.postscript_names { + // Try to find the postscript name in metadata (case-insensitive) + let style = postscript_names + .iter() + .find(|(key, _)| key.to_lowercase() == postscript_name.to_lowercase()) + .and_then(|(_, value)| value.as_str()); + + if let Some(style) = style { + if let Some(url) = files.get(style).and_then(|v| v.as_str()) { + font_files.push(FontFileInfo { + family: font.family.clone(), + postscript_name: postscript_name.clone(), + style: style.to_string(), + url: url.to_string(), + }); + } + } + } + } + } + } + } + + font_files +} + +pub fn find_font_files_by_family(metadata: &Value, font_families: &[String]) -> Vec { + let mut font_files = Vec::new(); + + for family in font_families { + // Try to find the font family in metadata (case-insensitive) + let font_data = metadata.as_object().and_then(|obj| { + obj.iter() + .find(|(key, _)| key.to_lowercase() == family.to_lowercase()) + .map(|(_, value)| value) + }); + + if let Some(font_data) = font_data { + if let Some(files) = font_data.get("files").and_then(|v| v.as_object()) { + // Get all available files + for (style, url) in files { + if let Some(url) = url.as_str() { + font_files.push(FontFileInfo { + family: family.clone(), + postscript_name: style.clone(), // Use style as postscript name + style: style.clone(), + url: url.to_string(), + }); + } + } + } + } + } + + font_files +} + +#[cfg(not(target_arch = "wasm32"))] +pub async fn fetch_webfont(url: &str) -> Result, Box> { + let response = reqwest::get(url).await?; + let bytes = response.bytes().await?; + Ok(bytes.to_vec()) +} + +#[cfg(target_arch = "wasm32")] +pub async fn fetch_webfont(url: &str) -> Result, Box> { + // Stub for wasm + Err("Webfont fetching not supported in wasm".into()) +} diff --git a/crates/grida-canvas/src/window/fps.rs b/crates/grida-canvas/src/window/fps.rs new file mode 100644 index 0000000000..25168a3f66 --- /dev/null +++ b/crates/grida-canvas/src/window/fps.rs @@ -0,0 +1,51 @@ +use skia_safe::{Color, Font, FontMgr, Paint, Point, Rect, Surface}; + +pub struct FpsMeter; + +thread_local! { + static BG_PAINT: Paint = { + let mut p = Paint::default(); + p.set_color(Color::from_argb(160, 0, 0, 0)); + p.set_anti_alias(true); + p + }; + + static TEXT_PAINT: Paint = { + let mut p = Paint::default(); + p.set_color(Color::WHITE); + p.set_anti_alias(true); + p + }; + + static FONT: Font = { + let font_mgr = FontMgr::new(); + let typeface = font_mgr + .match_family_style("Arial", skia_safe::FontStyle::default()) + .or_else(|| font_mgr.match_family_style("", skia_safe::FontStyle::default())); + match typeface { + Some(tf) => Font::new(tf, 36.0), + None => Font::default(), + } + }; +} + +impl FpsMeter { + pub fn draw(surface: &mut Surface, fps: f32) { + let canvas = surface.canvas(); + let rect = Rect::from_xywh(10.0, 10.0, 180.0, 60.0); + BG_PAINT.with(|bg| { + canvas.draw_rect(rect, bg); + }); + + TEXT_PAINT.with(|paint| { + FONT.with(|font| { + canvas.draw_str( + format!("{:.0} fps", fps), + Point::new(24.0, 50.0), + font, + paint, + ); + }); + }); + } +} diff --git a/crates/grida-canvas/src/window/hit_overlay.rs b/crates/grida-canvas/src/window/hit_overlay.rs new file mode 100644 index 0000000000..2034dbc769 --- /dev/null +++ b/crates/grida-canvas/src/window/hit_overlay.rs @@ -0,0 +1,64 @@ +use skia_safe::{Color, Font, FontMgr, Paint, PaintStyle, Point, Rect, Surface}; + +thread_local! { + static BG_PAINT: Paint = { + let mut p = Paint::default(); + p.set_color(Color::from_argb(160, 0, 0, 0)); + p.set_anti_alias(true); + p + }; + + static TEXT_PAINT: Paint = { + let mut p = Paint::default(); + p.set_color(Color::WHITE); + p.set_anti_alias(true); + p + }; + + static FONT: Font = { + let font_mgr = FontMgr::new(); + let typeface = font_mgr + .match_family_style("Arial", skia_safe::FontStyle::default()) + .or_else(|| font_mgr.match_family_style("", skia_safe::FontStyle::default())); + match typeface { + Some(tf) => Font::new(tf, 20.0), + None => Font::default(), + } + }; + + static STROKE: Paint = { + let mut p = Paint::default(); + p.set_color(Color::from_argb(200, 255, 0, 0)); + p.set_style(PaintStyle::Stroke); + p.set_stroke_width(2.0); + p.set_anti_alias(true); + p + }; +} + +pub struct HitOverlay; + +impl HitOverlay { + pub fn draw(surface: &mut Surface, hit: Option<(&str, Rect)>) { + let Some((id, rect)) = hit else { + return; + }; + let canvas = surface.canvas(); + + // background for text + let text_rect = Rect::from_xywh(10.0, 80.0, 300.0, 40.0); + BG_PAINT.with(|bg| { + canvas.draw_rect(text_rect, bg); + }); + + TEXT_PAINT.with(|paint| { + FONT.with(|font| { + canvas.draw_str(format!("hit: {}", id), Point::new(24.0, 104.0), font, paint); + }); + }); + + STROKE.with(|stroke| { + canvas.draw_rect(rect, stroke); + }); + } +} diff --git a/crates/grida-canvas/src/window/mod.rs b/crates/grida-canvas/src/window/mod.rs new file mode 100644 index 0000000000..85c4e69799 --- /dev/null +++ b/crates/grida-canvas/src/window/mod.rs @@ -0,0 +1,664 @@ +pub mod fps; +pub mod hit_overlay; +pub mod ruler; +pub mod scheduler; +pub mod stats_overlay; +pub mod tile_overlay; + +use crate::font_loader::FontLoader; +use crate::font_loader::FontMessage; +use crate::image_loader::ImageMessage; +use crate::image_loader::{ImageLoader, load_scene_images}; +use crate::node::schema::*; +use crate::repository::ResourceRepository; +use crate::runtime::camera::Camera2D; +use crate::runtime::scene::{Backend, Renderer}; +use console_error_panic_hook::set_once as init_panic_hook; +use gl::types::*; +use gl_rs as gl; +use glutin::{ + config::{ConfigTemplateBuilder, GlConfig}, + context::{ContextApi, ContextAttributesBuilder, PossiblyCurrentContext}, + display::{GetGlDisplay, GlDisplay}, + prelude::{GlSurface, NotCurrentGlContext}, + surface::{Surface as GlutinSurface, SurfaceAttributesBuilder, WindowSurface}, +}; +use glutin_winit::DisplayBuilder; +use math2::rect; +#[allow(deprecated)] +use raw_window_handle::HasRawWindowHandle; +use skia_safe::{Rect, Surface, gpu}; +use std::{ffi::CString, num::NonZeroU32}; +use tokio::sync::mpsc; +use winit::event::{ElementState, KeyEvent, MouseScrollDelta, WindowEvent}; +use winit::keyboard::Key; +use winit::{ + application::ApplicationHandler, + dpi::LogicalSize, + event_loop::EventLoop, + window::{Window, WindowAttributes}, +}; + +#[derive(Debug)] +enum Command { + Close, + ZoomIn, + ZoomOut, + ZoomDelta { delta: f32 }, + Pan { tx: f32, ty: f32 }, + Redraw, + Resize { width: u32, height: u32 }, + None, +} + +fn handle_window_event(event: &WindowEvent) -> Command { + match event { + WindowEvent::CloseRequested => Command::Close, + WindowEvent::Resized(size) => Command::Resize { + width: size.width, + height: size.height, + }, + WindowEvent::KeyboardInput { + event: + KeyEvent { + logical_key: key, + state: ElementState::Pressed, + .. + }, + .. + } => match key { + Key::Character(c) if c == "=" => Command::ZoomIn, + Key::Character(c) if c == "-" => Command::ZoomOut, + _ => Command::None, + }, + WindowEvent::PinchGesture { + device_id: _, + delta, + phase: _, + } => Command::ZoomDelta { + delta: *delta as f32, + }, + WindowEvent::MouseWheel { delta, .. } => match delta { + MouseScrollDelta::PixelDelta(delta) => Command::Pan { + tx: -(delta.x as f32), + ty: -(delta.y as f32), + }, + _ => Command::None, + }, + WindowEvent::RedrawRequested => Command::Redraw, + _ => Command::None, + } +} + +fn init_window( + width: i32, + height: i32, +) -> ( + *mut Surface, + EventLoop<()>, + Window, + GlutinSurface, + PossiblyCurrentContext, + glutin::config::Config, + gpu::gl::FramebufferInfo, + skia_safe::gpu::DirectContext, + f64, // scale factor +) { + init_panic_hook(); + println!("🔄 Window process started with PID: {}", std::process::id()); + + // Create event loop and window + let el = EventLoop::new().expect("Failed to create event loop"); + let window_attributes = WindowAttributes::default() + .with_title("Grida - grida-canvas / glutin / skia-safe::gpu::gl") + .with_inner_size(LogicalSize::new(width, height)); + + // Create GL config template + let template = ConfigTemplateBuilder::new() + .with_alpha_size(8) + .with_transparency(true); + + // Build display and get window + let display_builder = DisplayBuilder::new().with_window_attributes(window_attributes.into()); + let (window, gl_config) = display_builder + .build(&el, template, |mut configs| { + let mut best = configs.next().expect("no gl config available"); + for config in configs { + let transparency_check = config.supports_transparency().unwrap_or(false) + & !best.supports_transparency().unwrap_or(false); + if transparency_check || config.num_samples() < best.num_samples() { + best = config; + } + } + best + }) + .expect("failed to build window"); + println!("Picked a config with {} samples", gl_config.num_samples()); + let window = window.expect("Could not create window with OpenGL context"); + #[allow(deprecated)] + let raw_window_handle = window + .raw_window_handle() + .expect("Failed to retrieve RawWindowHandle"); + + // --- DPI handling --- + let scale_factor = window.scale_factor(); + // --- + + // The context creation part. It can be created before surface and that's how + // it's expected in multithreaded + multiwindow operation mode, since you + // can send NotCurrentContext, but not Surface. + let context_attributes = ContextAttributesBuilder::new().build(Some(raw_window_handle)); + + // Since glutin by default tries to create OpenGL core context, which may not be + // present we should try gles. + let fallback_context_attributes = ContextAttributesBuilder::new() + .with_context_api(ContextApi::Gles(None)) + .build(Some(raw_window_handle)); + + let not_current_gl_context = unsafe { + gl_config + .display() + .create_context(&gl_config, &context_attributes) + .unwrap_or_else(|_| { + gl_config + .display() + .create_context(&gl_config, &fallback_context_attributes) + .expect("failed to create context") + }) + }; + + let (width, height): (u32, u32) = window.inner_size().into(); + + let attrs = SurfaceAttributesBuilder::::new().build( + raw_window_handle, + NonZeroU32::new(width).unwrap_or(unsafe { NonZeroU32::new_unchecked(1) }), + NonZeroU32::new(height).unwrap_or(unsafe { NonZeroU32::new_unchecked(1) }), + ); + + let gl_surface = unsafe { + gl_config + .display() + .create_window_surface(&gl_config, &attrs) + .expect("Could not create gl window surface") + }; + + let gl_context = not_current_gl_context + .make_current(&gl_surface) + .expect("Could not make GL context current"); + + gl::load_with(|s| { + let Ok(cstr) = CString::new(s) else { + return std::ptr::null(); + }; + gl_config.display().get_proc_address(cstr.as_c_str()) + }); + + let interface = skia_safe::gpu::gl::Interface::new_load_with(|name| { + if name == "eglGetCurrentDisplay" { + return std::ptr::null(); + } + let Ok(cstr) = CString::new(name) else { + return std::ptr::null(); + }; + gl_config.display().get_proc_address(cstr.as_c_str()) + }) + .expect("Could not create interface"); + + let mut gr_context = skia_safe::gpu::direct_contexts::make_gl(interface, None) + .expect("Could not create direct context"); + + // Get framebuffer info + let fb_info = { + let mut fboid: GLint = 0; + unsafe { gl::GetIntegerv(gl::FRAMEBUFFER_BINDING, &mut fboid) }; + gpu::gl::FramebufferInfo { + fboid: fboid.try_into().unwrap_or_default(), + format: skia_safe::gpu::gl::Format::RGBA8.into(), + ..Default::default() + } + }; + + // Create Skia surface + let backend_render_target = gpu::backend_render_targets::make_gl( + (width as i32, height as i32), + gl_config.num_samples() as usize, + gl_config.stencil_size() as usize, + fb_info, + ); + + let surface = gpu::surfaces::wrap_backend_render_target( + &mut gr_context, + &backend_render_target, + skia_safe::gpu::SurfaceOrigin::BottomLeft, + skia_safe::ColorType::RGBA8888, + None, + None, + ) + .expect("Could not create skia surface"); + + ( + Box::into_raw(Box::new(surface)), + el, + window, + gl_surface, + gl_context, + gl_config, + fb_info, + gr_context, + scale_factor, + ) +} + +struct App { + renderer: Renderer, + surface_ptr: *mut Surface, + gl_surface: GlutinSurface, + gl_context: PossiblyCurrentContext, + gl_config: glutin::config::Config, + fb_info: gpu::gl::FramebufferInfo, + gr_context: skia_safe::gpu::DirectContext, + camera: Camera2D, + input: crate::runtime::input::InputState, + hit_result: Option, + last_hit_test: std::time::Instant, + window: Window, + image_rx: mpsc::UnboundedReceiver, + font_rx: mpsc::UnboundedReceiver, + scheduler: scheduler::FrameScheduler, + last_frame_time: std::time::Instant, + last_stats: Option, +} + +impl ApplicationHandler for App { + fn resumed(&mut self, _event_loop: &winit::event_loop::ActiveEventLoop) {} + + fn window_event( + &mut self, + event_loop: &winit::event_loop::ActiveEventLoop, + _window_id: winit::window::WindowId, + event: WindowEvent, + ) { + if let WindowEvent::CursorMoved { position, .. } = &event { + self.input.cursor = [position.x as f32, position.y as f32]; + self.perform_hit_test(); + } + + match handle_window_event(&event) { + Command::Close => { + self.renderer.free(); + event_loop.exit(); + } + Command::ZoomIn => { + let current_zoom = self.camera.get_zoom(); + self.camera.set_zoom(current_zoom * 1.2); + if self.renderer.set_camera(self.camera.clone()) { + self.renderer.queue(); + } + } + Command::ZoomOut => { + let current_zoom = self.camera.get_zoom(); + self.camera.set_zoom(current_zoom / 1.2); + if self.renderer.set_camera(self.camera.clone()) { + self.renderer.queue(); + } + } + Command::ZoomDelta { delta } => { + let current_zoom = self.camera.get_zoom(); + let zoom_factor = 1.0 + delta; + if zoom_factor.is_finite() && zoom_factor > 0.0 { + self.camera + .set_zoom_at(current_zoom * zoom_factor, self.input.cursor); + } + if self.renderer.set_camera(self.camera.clone()) { + self.renderer.queue(); + } + } + Command::Pan { tx, ty } => { + let zoom = self.camera.get_zoom(); + self.camera.translate(tx * (1.0 / zoom), ty * (1.0 / zoom)); + if self.renderer.set_camera(self.camera.clone()) { + self.renderer.queue(); + } + } + Command::Resize { width, height } => { + self.resize(width, height); + } + Command::Redraw => { + self.redraw(); + } + Command::None => {} + } + } + + fn user_event(&mut self, _event_loop: &winit::event_loop::ActiveEventLoop, _event: ()) { + self.window.request_redraw(); + } +} + +impl App { + fn process_image_queue(&mut self) { + let mut updated = false; + while let Ok(msg) = self.image_rx.try_recv() { + self.renderer.add_image(msg.src.clone(), &msg.data); + println!("📝 Registered image with renderer: {}", msg.src); + updated = true; + } + if updated { + self.renderer.invalidate_cache(); + } + } + + fn process_font_queue(&mut self) { + let mut updated = false; + let mut font_count = 0; + while let Ok(msg) = self.font_rx.try_recv() { + // Always use the base family name for registration + let family_name = &msg.family; + self.renderer.add_font(family_name, &msg.data); + + // Log the registration with style information if available + if let Some(style) = &msg.style { + println!( + "📝 Registered font with renderer: '{}' (style: {})", + family_name, style + ); + } else { + println!("📝 Registered font with renderer: '{}'", family_name); + } + font_count += 1; + updated = true; + } + if updated { + self.renderer.invalidate_cache(); + + // Print font repository information after processing fonts + if font_count > 0 { + self.print_font_repository_info(); + } + } + } + + fn print_font_repository_info(&self) { + let font_repo = self.renderer.font_repository.borrow(); + let family_count = font_repo.family_count(); + let total_font_count = font_repo.total_font_count(); + + println!("\n🔍 Font Repository Status:"); + println!("==========================="); + println!("Font families: {}", family_count); + println!("Total fonts: {}", total_font_count); + + if family_count > 0 { + println!("\n📋 Registered font families:"); + println!("---------------------------"); + for (i, (family_name, font_variants)) in font_repo.iter().enumerate() { + println!( + " {}. {} ({} variants)", + i + 1, + family_name, + font_variants.len() + ); + for (j, font_data) in font_variants.iter().enumerate() { + println!(" - Variant {}: {} bytes", j + 1, font_data.len()); + } + } + } + println!("✅ Font repository information printed"); + } + + /// Hit test the current cursor position and store the result. + fn perform_hit_test(&mut self) { + const HIT_TEST_INTERVAL: std::time::Duration = std::time::Duration::from_millis(50); + if self.last_hit_test.elapsed() < HIT_TEST_INTERVAL { + return; + } + self.last_hit_test = std::time::Instant::now(); + + let camera = &self.camera; + let point = camera.screen_to_canvas_point(self.input.cursor); + let tester = crate::hit_test::HitTester::new(self.renderer.scene_cache()); + + let new_hit_result = tester.hit_first(point); + if self.hit_result != new_hit_result { + self.renderer.queue(); + } + self.hit_result = new_hit_result; + } + + fn redraw(&mut self) { + let __frame_start = std::time::Instant::now(); + let __frame_delta = __frame_start.saturating_duration_since(self.last_frame_time); + + let __queue_start = std::time::Instant::now(); + self.process_image_queue(); + self.process_font_queue(); + let __queue_time = __queue_start.elapsed(); + + let stats = match self.renderer.flush() { + Some(stats) => stats, + None => return, + }; + + let mut overlay_flush_time = std::time::Duration::ZERO; + let overlay_draw_time: std::time::Duration; + + unsafe { + let __overlay_start = std::time::Instant::now(); + let surface = &mut *self.surface_ptr; + fps::FpsMeter::draw(surface, self.scheduler.average_fps()); + if let Some(s) = self.last_stats.as_deref() { + stats_overlay::StatsOverlay::draw(surface, s); + } + let hit_rect = if let Some(id) = self.hit_result.as_ref() { + if let Some(bounds) = self.renderer.scene_cache().geometry.get_render_bounds(id) { + let screen_rect = rect::transform(bounds, &self.camera.view_matrix()); + Some(Rect::from_xywh( + screen_rect.x, + screen_rect.y, + screen_rect.width, + screen_rect.height, + )) + } else { + None + } + } else { + None + }; + hit_overlay::HitOverlay::draw(surface, self.hit_result.as_deref().zip(hit_rect)); + if self.renderer.debug_tiles() { + tile_overlay::TileOverlay::draw( + surface, + &self.camera, + self.renderer.scene_cache().tile.tiles(), + ); + } + ruler::Ruler::draw(surface, &self.camera); + if let Some(mut ctx) = surface.recording_context() { + if let Some(mut direct) = ctx.as_direct_context() { + let __overlay_flush_start = std::time::Instant::now(); + direct.flush_and_submit(); + overlay_flush_time = __overlay_flush_start.elapsed(); + } + } + overlay_draw_time = __overlay_start.elapsed(); + } + + if let Err(e) = self.gl_surface.swap_buffers(&self.gl_context) { + eprintln!("Error swapping buffers: {:?}", e); + } + + // Apply frame pacing + let __sleep_start = std::time::Instant::now(); + self.scheduler.sleep_to_maintain_fps(); + let __sleep_time = __sleep_start.elapsed(); + + let __total_frame_time = __frame_start.elapsed(); + let stat_string = format!( + "fps*: {:.0} | t: {:.2}ms | render: {:.1}ms | flush: {:.1}ms | overlays: {:.1}ms | frame: {:.1}ms | list: {:.1}ms ({:?}) | draw: {:.1}ms | $:pic: {:?} ({:?} use) | $:geo: {:?} | tiles: {:?} ({:?} use) | q: {:?} | z: {:?}", + 1.0 / __total_frame_time.as_secs_f64(), + __total_frame_time.as_secs_f64() * 1000.0, + stats.total_duration.as_secs_f64() * 1000.0, + stats.flush_duration.as_secs_f64() * 1000.0, + (overlay_flush_time.as_secs_f64() + overlay_draw_time.as_secs_f64()) * 1000.0, + stats.frame_duration.as_secs_f64() * 1000.0, + stats.frame.display_list_duration.as_secs_f64() * 1000.0, + stats.frame.display_list_size_estimated, + stats.draw.painter_duration.as_secs_f64() * 1000.0, + stats.draw.cache_picture_size, + stats.draw.cache_picture_used, + stats.draw.cache_geometry_size, + stats.draw.tiles_total, + stats.draw.tiles_used, + __queue_time, + __sleep_time + ); + println!("{}", stat_string); + self.last_stats = Some(stat_string); + + self.last_frame_time = __frame_start; + + if stats.frame.should_repaint_all { + self.renderer.queue(); + } + } + + fn resize(&mut self, width: u32, height: u32) { + // Resize the existing GL surface instead of recreating it + self.gl_surface.resize( + &self.gl_context, + NonZeroU32::new(width).unwrap_or(unsafe { NonZeroU32::new_unchecked(1) }), + NonZeroU32::new(height).unwrap_or(unsafe { NonZeroU32::new_unchecked(1) }), + ); + + // Recreate Skia surface + let backend_render_target = gpu::backend_render_targets::make_gl( + (width as i32, height as i32), + self.gl_config.num_samples() as usize, + self.gl_config.stencil_size() as usize, + self.fb_info, + ); + let surface = gpu::surfaces::wrap_backend_render_target( + &mut self.gr_context, + &backend_render_target, + skia_safe::gpu::SurfaceOrigin::BottomLeft, + skia_safe::ColorType::RGBA8888, + None, + None, + ) + .expect("Could not create skia surface"); + + // Update surface pointer + unsafe { _ = Box::from_raw(self.surface_ptr) }; + self.surface_ptr = Box::into_raw(Box::new(surface)); + self.renderer.set_backend(Backend::GL(self.surface_ptr)); + self.renderer.invalidate_cache(); + + // Update camera viewport size to match the new surface dimensions + self.camera.size = Size { + width: width as f32, + height: height as f32, + }; + if self.renderer.set_camera(self.camera.clone()) { + self.renderer.queue(); + } + } +} + +#[allow(dead_code)] +pub async fn run_demo_window(scene: Scene) { + run_demo_window_with(scene, |_, _, _, _| {}).await; +} + +pub async fn run_demo_window_with(scene: Scene, init: F) +where + F: FnOnce( + &mut Renderer, + mpsc::UnboundedSender, + mpsc::UnboundedSender, + winit::event_loop::EventLoopProxy<()>, + ), +{ + let width = 1080; + let height = 1080; + + println!("🚀 Starting demo window..."); + let ( + surface_ptr, + el, + window, + gl_surface, + gl_context, + gl_config, + fb_info, + gr_context, + scale_factor, + ) = init_window(width, height); + + let (tx, rx) = mpsc::unbounded_channel(); + let (font_tx, font_rx) = mpsc::unbounded_channel(); + let proxy = el.create_proxy(); + + let mut renderer = Renderer::new(); + renderer.set_raf_callback({ + let proxy = proxy.clone(); + move || { + let _ = proxy.send_event(()); + } + }); + + renderer.set_debug_tiles(true); + renderer.set_backend(Backend::GL(surface_ptr)); + + // Initialize the image loader in lifecycle mode + println!("📸 Initializing image loader..."); + let mut image_loader = ImageLoader::new_lifecycle(tx.clone(), proxy.clone()); + let _font_loader = FontLoader::new_lifecycle(font_tx.clone(), proxy.clone()); + + // Load all images in the scene - non-blocking + println!("🔄 Starting to load scene images in background..."); + let scene_clone = scene.clone(); + tokio::spawn(async move { + load_scene_images(&mut image_loader, &scene_clone).await; + println!("✅ Scene images loading completed in background"); + }); + + // Call the init function + init(&mut renderer, tx, font_tx, proxy); + + // Create and set up camera + let camera = Camera2D::new(Size { + width: width as f32 * scale_factor as f32, + height: height as f32 * scale_factor as f32, + }); + // let camera = Camera2D::new(Size { + // width: width as f32, + // height: height as f32, + // }); + renderer.set_camera(camera.clone()); + renderer.load_scene(scene.clone()); + + let mut app = App { + renderer, + surface_ptr, + gl_surface, + gl_context, + gl_config, + fb_info, + gr_context, + camera, + input: crate::runtime::input::InputState::default(), + hit_result: None, + last_hit_test: std::time::Instant::now(), + window, + image_rx: rx, + font_rx, + scheduler: scheduler::FrameScheduler::new(144).with_max_fps(144), + last_frame_time: std::time::Instant::now(), + last_stats: None, + }; + + println!("🎭 Starting event loop..."); + if let Err(e) = el.run_app(&mut app) { + eprintln!("Event loop error: {:?}", e); + } +} diff --git a/crates/grida-canvas/src/window/ruler.rs b/crates/grida-canvas/src/window/ruler.rs new file mode 100644 index 0000000000..99b77e694d --- /dev/null +++ b/crates/grida-canvas/src/window/ruler.rs @@ -0,0 +1,127 @@ +use crate::runtime::camera::Camera2D; +use math2::{rect::Rectangle, vector2}; +use skia_safe::{Color, Font, FontMgr, Paint, PaintStyle, Path, Point, Surface}; +use std::cell::RefCell; + +pub struct Ruler; + +struct Cache { + rect: Rectangle, + zoom: f32, + path: Path, + v_labels: Vec<(Point, String)>, + h_labels: Vec<(Point, String)>, +} + +thread_local! { + static LINE_PAINT: Paint = { + let mut p = Paint::default(); + p.set_color(Color::from_argb(100, 200, 200, 200)); + p.set_style(PaintStyle::Stroke); + p.set_stroke_width(1.0); + p.set_anti_alias(true); + p + }; + + static TEXT_PAINT: Paint = { + let mut p = Paint::default(); + p.set_color(Color::from_argb(180, 220, 220, 220)); + p.set_anti_alias(true); + p + }; + + static FONT: Font = { + let font_mgr = FontMgr::new(); + let typeface = font_mgr + .match_family_style("Arial", skia_safe::FontStyle::default()) + .or_else(|| font_mgr.match_family_style("", skia_safe::FontStyle::default())); + match typeface { + Some(tf) => Font::new(tf, 10.0), + None => Font::default(), + } + }; + + static CACHE: RefCell> = RefCell::new(None); +} + +impl Ruler { + pub fn draw(surface: &mut Surface, camera: &Camera2D) { + const STEPS: [f32; 12] = [ + 1.0, 2.0, 5.0, 10.0, 25.0, 50.0, 100.0, 250.0, 500.0, 1000.0, 2500.0, 5000.0, + ]; + const MIN_PX_PER_TICK: f32 = 50.0; + let zoom = camera.get_zoom(); + let step = STEPS + .iter() + .find(|s| **s * zoom >= MIN_PX_PER_TICK) + .copied() + .unwrap_or_else(|| STEPS[STEPS.len() - 1]); + + let world_rect = camera.rect(); + let view = camera.view_matrix(); + + let canvas = surface.canvas(); + let width = camera.size.width; + let height = camera.size.height; + + CACHE.with(|c| { + let mut cache = c.borrow_mut(); + let needs_update = match &*cache { + Some(cached) => { + cached.rect != world_rect || (cached.zoom - zoom).abs() > f32::EPSILON + } + None => true, + }; + + if needs_update { + let mut path = Path::new(); + let mut v_labels = Vec::new(); + let mut h_labels = Vec::new(); + + let mut t = (world_rect.x / step).floor() * step; + while t <= world_rect.x + world_rect.width { + let p = vector2::transform([t, world_rect.y], &view); + let x = p[0]; + path.move_to(Point::new(x, 0.0)); + path.line_to(Point::new(x, height)); + v_labels.push((Point::new(x + 2.0, 12.0), format!("{:.0}", t))); + t += step; + } + + let mut t = (world_rect.y / step).floor() * step; + while t <= world_rect.y + world_rect.height { + let p = vector2::transform([world_rect.x, t], &view); + let y = p[1]; + path.move_to(Point::new(0.0, y)); + path.line_to(Point::new(width, y)); + h_labels.push((Point::new(2.0, y - 2.0), format!("{:.0}", t))); + t += step; + } + + *cache = Some(Cache { + rect: world_rect, + zoom, + path, + v_labels, + h_labels, + }); + } + + if let Some(cached) = &*cache { + LINE_PAINT.with(|line_paint| { + canvas.draw_path(&cached.path, line_paint); + }); + TEXT_PAINT.with(|text_paint| { + FONT.with(|font| { + for (pt, text) in cached.v_labels.iter() { + canvas.draw_str(text, *pt, font, text_paint); + } + for (pt, text) in cached.h_labels.iter() { + canvas.draw_str(text, *pt, font, text_paint); + } + }); + }); + } + }); + } +} diff --git a/crates/grida-canvas/src/window/scheduler.rs b/crates/grida-canvas/src/window/scheduler.rs new file mode 100644 index 0000000000..407f08c156 --- /dev/null +++ b/crates/grida-canvas/src/window/scheduler.rs @@ -0,0 +1,88 @@ +use std::collections::VecDeque; +use std::time::{Duration, Instant}; + +/// A module that controls frame pacing using target and max FPS limits, +/// while maintaining frame duration statistics for FPS estimation. +/// In WASM, the pacing logic is a no-op and the browser controls timing. +pub struct FrameScheduler { + last_frame_time: Instant, + target_frame_time: Duration, + max_frame_time: Option, + frame_durations: VecDeque, + max_samples: usize, +} + +impl FrameScheduler { + /// Creates a new scheduler with a given target FPS and rolling sample size. + pub fn new(target_fps: u32) -> Self { + Self { + last_frame_time: Instant::now(), + target_frame_time: Duration::from_micros(1_000_000 / target_fps as u64), + max_frame_time: None, + frame_durations: VecDeque::with_capacity(60), + max_samples: 60, + } + } + + /// Sets a maximum FPS cap to prevent over-drawing on high-refresh displays. + pub fn with_max_fps(mut self, max_fps: u32) -> Self { + self.max_frame_time = Some(Duration::from_micros(1_000_000 / max_fps as u64)); + self + } + + /// Records the most recent frame duration for smoothing. + fn record_frame_duration(&mut self, duration: Duration) { + if self.frame_durations.len() == self.max_samples { + self.frame_durations.pop_front(); + } + self.frame_durations.push_back(duration); + } + + /// Returns the average FPS based on the last N recorded frames. + pub fn average_fps(&self) -> f32 { + if self.frame_durations.is_empty() { + return 0.0; + } + + let total: Duration = self.frame_durations.iter().copied().sum(); + let avg = total / self.frame_durations.len() as u32; + 1_000_000.0 / avg.as_micros() as f32 + } + + /// No-op in WASM; browser controls frame rate via rAF. + #[cfg(target_arch = "wasm32")] + pub fn sleep_to_maintain_fps(&mut self) { + // no-op + } + + /// For native platforms, enforces frame pacing and tracks durations. + #[cfg(not(target_arch = "wasm32"))] + pub fn sleep_to_maintain_fps(&mut self) { + let now = Instant::now(); + let elapsed = now.duration_since(self.last_frame_time); + + let target = match self.max_frame_time { + Some(max_time) => self.target_frame_time.max(max_time), + None => self.target_frame_time, + }; + + if elapsed < target { + std::thread::sleep(target - elapsed); + } + + let end = Instant::now(); + let frame_duration = end.duration_since(self.last_frame_time); + self.record_frame_duration(frame_duration); + self.last_frame_time = end; + } + + /// Returns the configured target frame time. + pub fn get_target_frame_time(&self) -> Duration { + self.target_frame_time + } + + /// Returns the configured maximum frame time, if any. + pub fn get_max_frame_time(&self) -> Option { + self.max_frame_time + } +} diff --git a/crates/grida-canvas/src/window/stats_overlay.rs b/crates/grida-canvas/src/window/stats_overlay.rs new file mode 100644 index 0000000000..5eec9ec177 --- /dev/null +++ b/crates/grida-canvas/src/window/stats_overlay.rs @@ -0,0 +1,57 @@ +use skia_safe::{Color, Font, FontMgr, Paint, Point, Rect, Surface}; + +pub struct StatsOverlay; + +thread_local! { + static BG_PAINT: Paint = { + let mut p = Paint::default(); + p.set_color(Color::from_argb(160, 0, 0, 0)); + p.set_anti_alias(true); + p + }; + + static TEXT_PAINT: Paint = { + let mut p = Paint::default(); + p.set_color(Color::WHITE); + p.set_anti_alias(true); + p + }; + + static FONT: Font = { + let font_mgr = FontMgr::new(); + let typeface = font_mgr + .match_family_style("Arial", skia_safe::FontStyle::default()) + .or_else(|| font_mgr.match_family_style("", skia_safe::FontStyle::default())); + match typeface { + Some(tf) => Font::new(tf, 16.0), + None => Font::default(), + } + }; +} + +impl StatsOverlay { + pub fn draw(surface: &mut Surface, stats: &str) { + if stats.is_empty() { + return; + } + let lines: Vec<&str> = stats.split('|').map(|s| s.trim()).collect(); + if lines.is_empty() { + return; + } + let line_height = 20.0; + let padding = 10.0; + let width = 600.0; + let height = padding * 2.0 + line_height * lines.len() as f32; + let rect = Rect::from_xywh(10.0, 130.0, width, height); + let canvas = surface.canvas(); + BG_PAINT.with(|bg| canvas.draw_rect(rect, bg)); + TEXT_PAINT.with(|paint| { + FONT.with(|font| { + for (i, line) in lines.iter().enumerate() { + let y = rect.top + padding + line_height * (i as f32 + 1.0); + canvas.draw_str(*line, Point::new(rect.left + 14.0, y), font, paint); + } + }); + }); + } +} diff --git a/crates/grida-canvas/src/window/tile_overlay.rs b/crates/grida-canvas/src/window/tile_overlay.rs new file mode 100644 index 0000000000..0c1bc78b93 --- /dev/null +++ b/crates/grida-canvas/src/window/tile_overlay.rs @@ -0,0 +1,39 @@ +use crate::cache::tile::{TileAtZoom, TileRectKey}; +use crate::runtime::camera::Camera2D; +use math2::rect; +use skia_safe::{Color, Paint, PaintStyle, Rect, Surface}; +use std::collections::HashMap; + +pub struct TileOverlay; + +impl TileOverlay { + pub fn draw( + surface: &mut Surface, + camera: &Camera2D, + tiles: &HashMap, + ) { + if tiles.is_empty() { + return; + } + let canvas = surface.canvas(); + let stroke_width = 1.0; + let mut paint = Paint::default(); + paint.set_style(PaintStyle::Stroke); + paint.set_color(Color::from_argb(0xFF, 0x00, 0xFF, 0x00)); + paint.set_anti_alias(true); + paint.set_stroke_width(stroke_width); + + for key in tiles.keys() { + let rect = key.to_rect(); + let screen_rect = rect::transform(rect, &camera.view_matrix()); + let half = stroke_width * 0.5; + let r = Rect::from_xywh( + screen_rect.x + half, + screen_rect.y + half, + screen_rect.width - stroke_width, + screen_rect.height - stroke_width, + ); + canvas.draw_rect(r, &paint); + } + } +} diff --git a/crates/grida-canvas/tests/camera.rs b/crates/grida-canvas/tests/camera.rs new file mode 100644 index 0000000000..7bde010808 --- /dev/null +++ b/crates/grida-canvas/tests/camera.rs @@ -0,0 +1,18 @@ +use cg::node::schema::Size; +use cg::runtime::camera::Camera2D; + +#[test] +fn zoom_at_preserves_anchor() { + let mut cam = Camera2D::new(Size { + width: 200.0, + height: 200.0, + }); + cam.set_position(0.0, 0.0); + cam.set_zoom(1.0); + let anchor = [50.0, 50.0]; + let before = cam.screen_to_canvas_point(anchor); + cam.set_zoom_at(2.0, anchor); + let after = cam.screen_to_canvas_point(anchor); + assert!((before[0] - after[0]).abs() < f32::EPSILON); + assert!((before[1] - after[1]).abs() < f32::EPSILON); +} diff --git a/crates/grida-canvas/tests/dashed_stroke.rs b/crates/grida-canvas/tests/dashed_stroke.rs new file mode 100644 index 0000000000..650dac42bc --- /dev/null +++ b/crates/grida-canvas/tests/dashed_stroke.rs @@ -0,0 +1,15 @@ +use cg::node::schema::StrokeAlign; +use cg::painter::geometry::stroke_geometry; +use skia_safe::Path; + +#[test] +fn dashed_stroke_has_more_segments() { + let mut path = Path::new(); + path.move_to((0.0, 0.0)); + path.line_to((100.0, 0.0)); + + let solid = stroke_geometry(&path, 10.0, StrokeAlign::Center, None); + let dashed = stroke_geometry(&path, 10.0, StrokeAlign::Center, Some(&vec![10.0, 10.0])); + + assert!(dashed.count_verbs() > solid.count_verbs()); +} diff --git a/crates/grida-canvas/tests/geometry_cache.rs b/crates/grida-canvas/tests/geometry_cache.rs new file mode 100644 index 0000000000..7b4930a9bf --- /dev/null +++ b/crates/grida-canvas/tests/geometry_cache.rs @@ -0,0 +1,95 @@ +use cg::cache::geometry::GeometryCache; +use cg::node::{factory::NodeFactory, repository::NodeRepository, schema::*}; +use math2::transform::AffineTransform; + +#[test] +fn geometry_cache_builds_recursively() { + let nf = NodeFactory::new(); + let mut repo = NodeRepository::new(); + + let mut rect = nf.create_rectangle_node(); + rect.transform = AffineTransform::new(4.0, 6.0, 0.0); + let rect_id = rect.base.id.clone(); + repo.insert(Node::Rectangle(rect)); + + let mut group2 = nf.create_group_node(); + group2.transform = AffineTransform::new(2.0, 3.0, 0.0); + group2.children.push(rect_id.clone()); + let group2_id = group2.base.id.clone(); + repo.insert(Node::Group(group2)); + + let mut group1 = nf.create_group_node(); + group1.transform = AffineTransform::new(5.0, 5.0, 0.0); + group1.children.push(group2_id.clone()); + let group1_id = group1.base.id.clone(); + repo.insert(Node::Group(group1)); + + let mut container = nf.create_container_node(); + container.transform = AffineTransform::new(10.0, 20.0, 0.0); + container.children.push(group1_id.clone()); + let container_id = container.base.id.clone(); + repo.insert(Node::Container(container)); + + let scene = Scene { + id: "scene".into(), + name: "test".into(), + transform: AffineTransform::identity(), + children: vec![container_id.clone()], + nodes: repo.clone(), + background_color: None, + }; + + let cache = GeometryCache::from_scene(&scene); + assert_eq!(cache.len(), repo.len()); + + let expected = AffineTransform::new(21.0, 34.0, 0.0); + assert_eq!( + cache.get_world_transform(&rect_id).unwrap().matrix, + expected.matrix + ); + assert!(cache.has(&container_id)); + assert!(cache.has(&group1_id)); + assert!(cache.has(&group2_id)); +} + +#[test] +fn container_world_bounds_include_children() { + let nf = NodeFactory::new(); + let mut repo = NodeRepository::new(); + + let mut rect = nf.create_rectangle_node(); + rect.transform = AffineTransform::new(50.0, 50.0, 0.0); + rect.size = Size { + width: 100.0, + height: 100.0, + }; + let rect_id = rect.base.id.clone(); + repo.insert(Node::Rectangle(rect)); + + let mut container = nf.create_container_node(); + container.size = Size { + width: 100.0, + height: 100.0, + }; + container.children.push(rect_id.clone()); + let container_id = container.base.id.clone(); + repo.insert(Node::Container(container)); + + let scene = Scene { + id: "scene".into(), + name: "test".into(), + transform: AffineTransform::identity(), + children: vec![container_id.clone()], + nodes: repo, + background_color: None, + }; + + let cache = GeometryCache::from_scene(&scene); + let bounds = cache.get_world_bounds(&container_id).unwrap(); + assert_eq!(bounds.x, 0.0); + assert_eq!(bounds.y, 0.0); + assert_eq!(bounds.width, 100.0); + assert_eq!(bounds.height, 100.0); + // child bounds also exist + assert!(cache.has(&rect_id)); +} diff --git a/crates/grida-canvas/tests/hit_test.rs b/crates/grida-canvas/tests/hit_test.rs new file mode 100644 index 0000000000..b2b8ff2e9b --- /dev/null +++ b/crates/grida-canvas/tests/hit_test.rs @@ -0,0 +1,53 @@ +use cg::cache::scene::SceneCache; +use cg::hit_test::HitTester; +use cg::node::{factory::NodeFactory, repository::NodeRepository, schema::*}; +use math2::transform::AffineTransform; + +#[test] +fn hit_first_returns_topmost() { + let nf = NodeFactory::new(); + let mut repo = NodeRepository::new(); + + let mut rect = nf.create_rectangle_node(); + rect.transform = AffineTransform::new(10.0, 10.0, 0.0); + rect.size = Size { + width: 20.0, + height: 20.0, + }; + let rect_id = rect.base.id.clone(); + repo.insert(Node::Rectangle(rect)); + + let mut container = nf.create_container_node(); + container.size = Size { + width: 40.0, + height: 40.0, + }; + let container_id = container.base.id.clone(); + container.children.push(rect_id.clone()); + repo.insert(Node::Container(container)); + + let scene = Scene { + id: "scene".into(), + name: "test".into(), + transform: AffineTransform::identity(), + children: vec![container_id.clone()], + nodes: repo, + background_color: None, + }; + + let mut cache = SceneCache::new(); + cache.update_geometry(&scene); + cache.update_layers(&scene); + + let tester = HitTester::new(&cache); + + assert_eq!( + tester.hit_first([15.0, 15.0]).as_deref(), + Some(rect_id.as_str()) + ); + assert_eq!( + tester.hit_first([5.0, 5.0]).as_deref(), + Some(container_id.as_str()) + ); + assert!(tester.hit_first([100.0, 100.0]).is_none()); +} diff --git a/crates/grida-canvas/tests/mipmap.rs b/crates/grida-canvas/tests/mipmap.rs new file mode 100644 index 0000000000..b07db0e864 --- /dev/null +++ b/crates/grida-canvas/tests/mipmap.rs @@ -0,0 +1,53 @@ +use cg::mipmap::{ImageMipmaps, MipmapConfig, MipmapLevels}; +use skia_safe::surfaces; + +#[test] +fn full_chain_down_to_1x1() { + let width = 300; + let height = 200; + + let mut surface = surfaces::raster_n32_premul((width, height)).unwrap(); + let image = surface.image_snapshot(); + + let config = MipmapConfig { + levels: MipmapLevels::FullChain, + chained: true, + }; + let mip = ImageMipmaps::from_image(image, &config); + let expected_levels = (width.max(height) as f32).log2().ceil() as usize + 1; + assert_eq!(mip.level_count(), expected_levels); + + let last = mip.last_level_image().unwrap(); + assert!(last.width() <= 1 && last.height() <= 1); +} + +#[test] +fn best_for_size_selects_correct_level() { + let mut surface = surfaces::raster_n32_premul((300, 200)).unwrap(); + let image = surface.image_snapshot(); + + let config = MipmapConfig { + levels: MipmapLevels::FullChain, + chained: true, + }; + let mip = ImageMipmaps::from_image(image, &config); + let level = mip.best_for_size(100.0, 100.0).unwrap(); + + assert_eq!(level.width(), 150); + assert_eq!(level.height(), 100); +} + +#[test] +fn handles_nan_in_levels() { + let mut surface = surfaces::raster_n32_premul((100, 100)).unwrap(); + let image = surface.image_snapshot(); + + let config = MipmapConfig { + levels: MipmapLevels::Fixed(vec![1.0, f32::NAN, 0.5]), + chained: true, + }; + + // should not panic + let mip = ImageMipmaps::from_image(image, &config); + assert_eq!(mip.level_count(), 3); +} diff --git a/crates/grida-canvas/tests/render_bounds.rs b/crates/grida-canvas/tests/render_bounds.rs new file mode 100644 index 0000000000..fdeaf66f19 --- /dev/null +++ b/crates/grida-canvas/tests/render_bounds.rs @@ -0,0 +1,90 @@ +use cg::cache::geometry::GeometryCache; +use cg::node::{factory::NodeFactory, repository::NodeRepository, schema::*}; +use math2::transform::AffineTransform; + +#[test] +fn stroke_affects_render_bounds() { + let nf = NodeFactory::new(); + let mut repo = NodeRepository::new(); + + let mut rect = nf.create_rectangle_node(); + rect.stroke_width = 10.0; + rect.stroke_align = StrokeAlign::Outside; + let rect_id = rect.base.id.clone(); + repo.insert(Node::Rectangle(rect)); + + let scene = Scene { + id: "scene".into(), + name: "test".into(), + transform: AffineTransform::identity(), + children: vec![rect_id.clone()], + nodes: repo, + background_color: None, + }; + + let cache = GeometryCache::from_scene(&scene); + let bounds = cache.get_render_bounds(&rect_id).unwrap(); + assert_eq!(bounds.x, -10.0); + assert_eq!(bounds.y, -10.0); + assert_eq!(bounds.width, 120.0); + assert_eq!(bounds.height, 120.0); +} + +#[test] +fn gaussian_blur_expands_render_bounds() { + let nf = NodeFactory::new(); + let mut repo = NodeRepository::new(); + + let mut rect = nf.create_rectangle_node(); + rect.effect = Some(FilterEffect::GaussianBlur(FeGaussianBlur { radius: 5.0 })); + let rect_id = rect.base.id.clone(); + repo.insert(Node::Rectangle(rect)); + + let scene = Scene { + id: "scene".into(), + name: "test".into(), + transform: AffineTransform::identity(), + children: vec![rect_id.clone()], + nodes: repo, + background_color: None, + }; + + let cache = GeometryCache::from_scene(&scene); + let bounds = cache.get_render_bounds(&rect_id).unwrap(); + assert_eq!(bounds.x, -5.0); + assert_eq!(bounds.y, -5.0); + assert_eq!(bounds.width, 110.0); + assert_eq!(bounds.height, 110.0); +} + +#[test] +fn drop_shadow_expands_render_bounds() { + let nf = NodeFactory::new(); + let mut repo = NodeRepository::new(); + + let mut rect = nf.create_rectangle_node(); + rect.effect = Some(FilterEffect::DropShadow(FeDropShadow { + dx: 5.0, + dy: 5.0, + blur: 10.0, + color: Color(0, 0, 0, 255), + })); + let rect_id = rect.base.id.clone(); + repo.insert(Node::Rectangle(rect)); + + let scene = Scene { + id: "scene".into(), + name: "test".into(), + transform: AffineTransform::identity(), + children: vec![rect_id.clone()], + nodes: repo, + background_color: None, + }; + + let cache = GeometryCache::from_scene(&scene); + let bounds = cache.get_render_bounds(&rect_id).unwrap(); + assert_eq!(bounds.x, -5.0); + assert_eq!(bounds.y, -5.0); + assert_eq!(bounds.width, 120.0); + assert_eq!(bounds.height, 120.0); +} diff --git a/crates/grida-canvas/tests/resource_loader.rs b/crates/grida-canvas/tests/resource_loader.rs new file mode 100644 index 0000000000..296376aea0 --- /dev/null +++ b/crates/grida-canvas/tests/resource_loader.rs @@ -0,0 +1,31 @@ +use cg::font_loader::FontLoader; +use cg::image_loader::ImageLoader; +use cg::resource_loader::ResourceLoader; + +use std::path::PathBuf; + +fn resource_path(name: &str) -> String { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("resources") + .join(name) + .to_string_lossy() + .to_string() +} + +#[tokio::test] +async fn font_loader_simple_load_unload() { + let mut loader = FontLoader::new_simple(); + let path = resource_path("Caveat-VariableFont_wght.ttf"); + let data = loader.load("Caveat", &path).await; + assert!(data.is_some()); + loader.unload("Caveat").await; +} + +#[tokio::test] +async fn image_loader_simple_load_unload() { + let mut loader = ImageLoader::new_simple(); + let path = resource_path("4k.jpg"); + let data = loader.load(&path, &path).await; + assert!(data.is_some()); + loader.unload(&path).await; +} diff --git a/crates/grida-canvas/tests/scene_cache.rs b/crates/grida-canvas/tests/scene_cache.rs new file mode 100644 index 0000000000..ffa712a23f --- /dev/null +++ b/crates/grida-canvas/tests/scene_cache.rs @@ -0,0 +1,66 @@ +use cg::cache::scene::SceneCache; +use cg::node::{factory::NodeFactory, repository::NodeRepository, schema::*}; +use cg::painter::layer::Layer; +use math2::rect::Rectangle; +use math2::transform::AffineTransform; + +#[test] +fn layers_in_rect_include_partially_visible_nested() { + let nf = NodeFactory::new(); + let mut repo = NodeRepository::new(); + + let mut rect = nf.create_rectangle_node(); + rect.transform = AffineTransform::new(50.0, 50.0, 0.0); + rect.size = Size { + width: 100.0, + height: 100.0, + }; + let rect_id = rect.base.id.clone(); + repo.insert(Node::Rectangle(rect)); + + let mut container = nf.create_container_node(); + container.size = Size { + width: 100.0, + height: 100.0, + }; + let container_id = container.base.id.clone(); + container.children.push(rect_id.clone()); + repo.insert(Node::Container(container)); + + let scene = Scene { + id: "scene".into(), + name: "test".into(), + transform: AffineTransform::identity(), + children: vec![container_id.clone()], + nodes: repo, + background_color: None, + }; + + let mut cache = SceneCache::new(); + cache.update_geometry(&scene); + cache.update_layers(&scene); + + // Query area partially overlapping the rectangle only + + let layer_indices = cache.intersects(Rectangle { + x: 140.0, + y: 40.0, + width: 20.0, + height: 20.0, + }); + assert_eq!(layer_indices.len(), 1); + let layer = &cache.layers.layers[layer_indices[0]]; + assert_eq!(layer.id(), &rect_id); + + let layer_indices = cache.intersects(Rectangle { + x: 50.0, + y: 0.0, + width: 100.0, + height: 100.0, + }); + assert_eq!(layer_indices.len(), 2); + let layer0 = &cache.layers.layers[layer_indices[0]]; + let layer1 = &cache.layers.layers[layer_indices[1]]; + assert_eq!(layer0.id(), &rect_id); + assert_eq!(layer1.id(), &container_id); +} diff --git a/crates/grida-math2/Cargo.toml b/crates/grida-math2/Cargo.toml new file mode 100644 index 0000000000..11a4bcfb64 --- /dev/null +++ b/crates/grida-math2/Cargo.toml @@ -0,0 +1,15 @@ + +[package] +name = "math2" +version = "0.0.2" +edition = "2024" +description = "Geometry, layout and rasterization utilities powering the Grida canvas" +license = "MIT" +repository = "https://github.com/gridaco/grida" +homepage = "https://grida.co" +keywords = ["canvas", "geometry", "layout", "rasterization", "bezier"] +categories = ["algorithms", "graphics", "mathematics", "data-structures"] +authors = ["Grida"] + +[lib] +crate-type = ["cdylib", "rlib"] diff --git a/crates/grida-math2/LICENSE b/crates/grida-math2/LICENSE new file mode 100644 index 0000000000..e1e8c62d9c --- /dev/null +++ b/crates/grida-math2/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Grida + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/crates/grida-math2/README.md b/crates/grida-math2/README.md new file mode 100644 index 0000000000..0daa64930c --- /dev/null +++ b/crates/grida-math2/README.md @@ -0,0 +1,48 @@ +# math2 + +`math2` is a collection of lightweight geometry and math utilities used across the Grida canvas engine. It includes helpers for working with vectors, rectangles, affine transforms, rasterization and many other 2D operations. + +This crate is a direct port of the original TypeScript [`grida-cmath`](../../packages/grida-cmath) library. The APIs mostly follow the functional style of the source project. + +## Features + +- Vector and rectangle primitives +- Affine transform helpers +- Bézier conversion and bounding box computation +- Rasterization algorithms (bresenham, circle/ellipse, flood fill, Gaussian blur, etc.) +- Snapping, layout and packing utilities +- Color conversions (HEX, RGBA) + +## Installation + +Add the crate to your `Cargo.toml`: + +```toml +[dependencies] +math2 = {} +``` + +## Example + +```rust +use math2::{Rectangle, vector2, rect_transform}; +use math2::transform::AffineTransform; + +let rect = Rectangle { x: 0.0, y: 0.0, width: 100.0, height: 50.0 }; +let transform = AffineTransform::translate(10.0, 20.0); +let moved = rect_transform(rect, &transform); +assert_eq!(moved.x, 10.0); +assert_eq!(moved.y, 20.0); +``` + +## Suggestions for future improvements + +The current code mirrors the functional API of the original TypeScript version. Some potential refinements for a more idiomatic Rust API include: + +- Exposing common operations as methods on types like `Vector2` and `Rectangle`. +- Implementing standard traits (`Add`, `Sub`, etc.) for math types. +- Splitting optional algorithms (e.g. rasterization) behind crate features for `no_std` builds. + +## License + +Licensed under the MIT license. diff --git a/crates/grida-math2/package.json b/crates/grida-math2/package.json new file mode 100644 index 0000000000..72b719749e --- /dev/null +++ b/crates/grida-math2/package.json @@ -0,0 +1,9 @@ +{ + "name": "@crates/math2", + "description": "turbo ci rust wrapper", + "private": true, + "scripts": { + "build": "cargo build --release", + "test": "cargo test" + } +} diff --git a/crates/grida-math2/src/align.rs b/crates/grida-math2/src/align.rs new file mode 100644 index 0000000000..ec1dfd892e --- /dev/null +++ b/crates/grida-math2/src/align.rs @@ -0,0 +1,95 @@ +use super::vector2::{Vector2, distance}; + +/// Aligns a scalar value to the nearest value in `targets` if it is within +/// `threshold`. +/// +/// Returns a tuple `(value, distance, indices)` where: +/// - `value` is the snapped scalar or the original `point` if no target is +/// within `threshold`. +/// - `distance` is the signed distance `point - value` (or `Infinity` if not +/// snapped). +/// - `indices` contains all indices of `targets` that are equally close. +/// +/// # Panics +/// Panics if `threshold` is negative or `targets` is empty. +/// +/// # Example +/// ``` +/// let (value, dist, idx) = math2::align_scalar(22.0, &[10.0,20.0,20.0,40.0], 5.0); +/// assert_eq!(value, 20.0); +/// assert_eq!(dist, 2.0); +/// assert_eq!(idx, vec![1,2]); +/// ``` +pub fn scalar(point: f32, targets: &[f32], threshold: f32) -> (f32, f32, Vec) { + assert!(threshold >= 0.0, "threshold must be non-negative"); + assert!(!targets.is_empty(), "at least one target is required"); + + let mut min_abs = f32::INFINITY; + let mut best_value = point; + let mut best_signed = 0.0; + let mut indices = Vec::new(); + + for (i, &t) in targets.iter().enumerate() { + let signed = point - t; + let abs = signed.abs(); + if abs < min_abs { + min_abs = abs; + best_value = t; + best_signed = signed; + indices.clear(); + indices.push(i); + } else if abs == min_abs { + indices.push(i); + } + } + + if min_abs > threshold { + return (point, f32::INFINITY, Vec::new()); + } + + (best_value, best_signed, indices) +} + +/// Aligns a 2D point to the nearest vector within `threshold` using Euclidean +/// distance. +/// +/// Returns `(value, distance, indices)` where `value` is the snapped vector (or +/// the original `point` if no target is within `threshold`), `distance` is the +/// Euclidean distance, and `indices` lists all targets tied for the minimum +/// distance. +/// +/// # Panics +/// Panics if `threshold` is negative or `targets` is empty. +/// +/// # Example +/// ``` +/// let (val, dist, idx) = math2::align_vector2([6.0,6.0], &[[0.0,0.0],[5.0,5.0],[5.0,5.0],[10.0,10.0]], 3.0); +/// assert_eq!(val, [5.0,5.0]); +/// assert_eq!(idx, vec![1,2]); +/// ``` +pub fn vector2(point: Vector2, targets: &[Vector2], threshold: f32) -> (Vector2, f32, Vec) { + assert!(threshold >= 0.0, "threshold must be non-negative"); + assert!(!targets.is_empty(), "at least one target is required"); + + let mut min_dist = f32::INFINITY; + let mut best_value = point; + let mut indices = Vec::new(); + + for (i, &t) in targets.iter().enumerate() { + let dist = distance(point, t); + if dist < min_dist { + min_dist = dist; + best_value = t; + indices.clear(); + indices.push(i); + } else if dist == min_dist { + indices.push(i); + } + } + + if min_dist > threshold { + return (point, f32::INFINITY, Vec::new()); + } + + (best_value, min_dist, indices) +} diff --git a/crates/grida-math2/src/bezier.rs b/crates/grida-math2/src/bezier.rs new file mode 100644 index 0000000000..25f2528e42 --- /dev/null +++ b/crates/grida-math2/src/bezier.rs @@ -0,0 +1,250 @@ +use super::rect::{Rectangle, from_points}; +use super::vector2::Vector2; + +/// Represents a cubic Bézier curve segment with absolute control points. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct CubicBezier { + pub x1: f32, + pub y1: f32, + pub x2: f32, + pub y2: f32, + pub x: f32, + pub y: f32, +} + +/// A cubic Bézier segment expressed with tangents relative to the start/end vertices. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct CubicBezierWithTangents { + pub a: Vector2, + pub b: Vector2, + pub ta: Vector2, + pub tb: Vector2, +} + +fn solve_quad(a: f32, b: f32, c: f32) -> Vec { + let d = b * b - 4.0 * a * c; + if d < 0.0 { + Vec::new() + } else if d == 0.0 { + vec![-b / (2.0 * a)] + } else { + let sqrt_d = d.sqrt(); + vec![(-b + sqrt_d) / (2.0 * a), (-b - sqrt_d) / (2.0 * a)] + } +} + +fn cubic_deriv_coeffs(p0: f32, p1: f32, p2: f32, p3: f32) -> (f32, f32, f32) { + let c0 = -p0 + 3.0 * p1 - 3.0 * p2 + p3; // t^3 + let c1 = 3.0 * p0 - 6.0 * p1 + 3.0 * p2; // t^2 + let c2 = -3.0 * p0 + 3.0 * p1; // t^1 + (3.0 * c0, 2.0 * c1, c2) +} + +fn cubic_eval(p0: f32, p1: f32, p2: f32, p3: f32, t: f32) -> f32 { + let mt = 1.0 - t; + mt * mt * mt * p0 + 3.0 * mt * mt * t * p1 + 3.0 * mt * t * t * p2 + t * t * t * p3 +} + +/// Calculates the bounding box of a cubic Bézier segment expressed with tangents. +pub fn get_bbox(segment: &CubicBezierWithTangents) -> Rectangle { + let CubicBezierWithTangents { a, b, ta, tb } = *segment; + if ta[0] == 0.0 && ta[1] == 0.0 && tb[0] == 0.0 && tb[1] == 0.0 { + return from_points(&[a, b]); + } + let c1: Vector2 = [a[0] + ta[0], a[1] + ta[1]]; + let c2: Vector2 = [b[0] + tb[0], b[1] + tb[1]]; + + let (dx0, dx1, dx2) = cubic_deriv_coeffs(a[0], c1[0], c2[0], b[0]); + let (dy0, dy1, dy2) = cubic_deriv_coeffs(a[1], c1[1], c2[1], b[1]); + let tx = solve_quad(dx0, dx1, dx2); + let ty = solve_quad(dy0, dy1, dy2); + + let mut candidates = vec![0.0f32, 1.0f32]; + for t in tx { + if (0.0..=1.0).contains(&t) { + candidates.push(t); + } + } + for t in ty { + if (0.0..=1.0).contains(&t) { + candidates.push(t); + } + } + + let mut min_x = f32::INFINITY; + let mut min_y = f32::INFINITY; + let mut max_x = f32::NEG_INFINITY; + let mut max_y = f32::NEG_INFINITY; + + for t in candidates { + let x = cubic_eval(a[0], c1[0], c2[0], b[0], t); + let y = cubic_eval(a[1], c1[1], c2[1], b[1], t); + if x < min_x { + min_x = x; + } + if x > max_x { + max_x = x; + } + if y < min_y { + min_y = y; + } + if y > max_y { + max_y = y; + } + } + + Rectangle { + x: min_x, + y: min_y, + width: max_x - min_x, + height: max_y - min_y, + } +} + +/// Converts an SVG elliptical arc to cubic Bézier curve segments. +/// +/// Returned vector is flattened as `[c1x, c1y, c2x, c2y, x, y, ...]`. +#[allow(clippy::many_single_char_names)] +pub fn a2c( + mut x1: f32, + mut y1: f32, + mut rx: f32, + mut ry: f32, + angle: f32, + large_arc_flag: bool, + sweep_flag: bool, + mut x2: f32, + mut y2: f32, + recursive: Option<(f32, f32, f32, f32)>, +) -> Vec { + let pi = std::f32::consts::PI; + let _120 = pi * 120.0 / 180.0; + let rad = pi / 180.0 * angle; + + let rotate = |x: f32, y: f32, r: f32| -> (f32, f32) { + (x * r.cos() - y * r.sin(), x * r.sin() + y * r.cos()) + }; + + if rx == 0.0 || ry == 0.0 { + return vec![x1, y1, x2, y2, x2, y2]; + } + + let (f1, mut f2, cx, cy) = if let Some((rf1, rf2, rcx, rcy)) = recursive { + (rf1, rf2, rcx, rcy) + } else { + let (rx1, ry1) = rotate(x1, y1, -rad); + x1 = rx1; + y1 = ry1; + let (rx2, ry2) = rotate(x2, y2, -rad); + x2 = rx2; + y2 = ry2; + + let x = (x1 - x2) / 2.0; + let y = (y1 - y2) / 2.0; + + let h = x * x / (rx * rx) + y * y / (ry * ry); + if h > 1.0 { + let h_sqrt = h.sqrt(); + rx *= h_sqrt; + ry *= h_sqrt; + } + + let rx2s = rx * rx; + let ry2s = ry * ry; + let k_sign = if large_arc_flag == sweep_flag { + -1.0 + } else { + 1.0 + }; + let k = k_sign + * ((rx2s * ry2s - rx2s * y * y - ry2s * x * x) / (rx2s * y * y + ry2s * x * x)) + .abs() + .sqrt(); + let cx = k * rx * y / ry + (x1 + x2) / 2.0; + let cy = k * -ry * x / rx + (y1 + y2) / 2.0; + let mut f1 = ((y1 - cy) / ry).clamp(-1.0, 1.0).asin(); + let mut f2 = ((y2 - cy) / ry).clamp(-1.0, 1.0).asin(); + if x1 < cx { + f1 = pi - f1; + } + if x2 < cx { + f2 = pi - f2; + } + if f1 < 0.0 { + f1 += pi * 2.0; + } + if f2 < 0.0 { + f2 += pi * 2.0; + } + if sweep_flag && f1 > f2 { + f1 -= pi * 2.0; + } + if !sweep_flag && f2 > f1 { + f2 -= pi * 2.0; + } + (f1, f2, cx, cy) + }; + + let mut df = f2 - f1; + let mut res: Vec = Vec::new(); + + if df.abs() > _120 { + let f2old = f2; + let x2old = x2; + let y2old = y2; + f2 = f1 + _120 * if sweep_flag && f2 > f1 { 1.0 } else { -1.0 }; + x2 = cx + rx * f2.cos(); + y2 = cy + ry * f2.sin(); + res = a2c( + x2, + y2, + rx, + ry, + angle, + false, + sweep_flag, + x2old, + y2old, + Some((f2, f2old, cx, cy)), + ); + } + + df = f2 - f1; + let c1 = f1.cos(); + let s1 = f1.sin(); + let c2 = f2.cos(); + let s2 = f2.sin(); + let t = (df / 4.0).tan(); + let hx = (4.0 / 3.0) * rx * t; + let hy = (4.0 / 3.0) * ry * t; + + let m1 = [x1, y1]; + let mut m2 = [x1 + hx * s1, y1 - hy * c1]; + let m3 = [x2 + hx * s2, y2 - hy * c2]; + let m4 = [x2, y2]; + m2[0] = 2.0 * m1[0] - m2[0]; + m2[1] = 2.0 * m1[1] - m2[1]; + + let mut points: Vec<[f32; 2]> = vec![m2, m3, m4]; + for chunk in res.chunks(2) { + if let [x, y] = chunk { + points.push([*x, *y]); + } + } + + let mut flat = Vec::with_capacity(points.len() * 2); + if recursive.is_some() { + for p in points { + flat.push(p[0]); + flat.push(p[1]); + } + } else { + for p in points { + let (x, y) = rotate(p[0], p[1], rad); + flat.push(x); + flat.push(y); + } + } + + flat +} diff --git a/crates/grida-math2/src/box_fit.rs b/crates/grida-math2/src/box_fit.rs new file mode 100644 index 0000000000..2cd09bfb5d --- /dev/null +++ b/crates/grida-math2/src/box_fit.rs @@ -0,0 +1,108 @@ +use crate::transform::AffineTransform; + +/// Supported fit modes. +/// +/// Only `Contain`, `Cover`, and `None` are supported in the current version. +/// +/// - `None` may have unexpected results depending on the environment. +/// +/// @see https://api.flutter.dev/flutter/painting/BoxFit.html +/// @see https://developer.mozilla.org/en-US/docs/Web/CSS/object-fit +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BoxFit { + Contain, + Cover, + None, +} + +impl BoxFit { + /// Calculates the transform needed to fit content of size `content_size` into a container of size `container_size` + /// according to the specified fit mode. + pub fn calculate_transform( + &self, + content_size: (f32, f32), + container_size: (f32, f32), + ) -> AffineTransform { + let (content_width, content_height) = content_size; + let (container_width, container_height) = container_size; + + // Determine scale factors + let (scale_x, scale_y) = match self { + BoxFit::None => (1.0, 1.0), + BoxFit::Contain => { + let scale = + (container_width / content_width).min(container_height / content_height); + (scale, scale) + } + BoxFit::Cover => { + let scale = + (container_width / content_width).max(container_height / content_height); + (scale, scale) + } + }; + + // Compute scaled dimensions + let scaled_width = content_width * scale_x; + let scaled_height = content_height * scale_y; + + // Center content in container + let tx = (container_width - scaled_width) / 2.0; + let ty = (container_height - scaled_height) / 2.0; + + AffineTransform { + matrix: [[scale_x, 0.0, tx], [0.0, scale_y, ty]], + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_box_fit_none() { + let t = BoxFit::None.calculate_transform((100.0, 100.0), (200.0, 200.0)); + assert_eq!(t.matrix[0][2], 50.0); + assert_eq!(t.matrix[1][2], 50.0); + assert_eq!(t.matrix[0][0], 1.0); + assert_eq!(t.matrix[1][1], 1.0); + } + + #[test] + fn test_box_fit_contain() { + let t = BoxFit::Contain.calculate_transform((100.0, 100.0), (200.0, 200.0)); + // Scaled to 200x200, so centered at (0,0) + assert_eq!(t.matrix[0][2], 0.0); + assert_eq!(t.matrix[1][2], 0.0); + assert_eq!(t.matrix[0][0], 2.0); + assert_eq!(t.matrix[1][1], 2.0); + } + + #[test] + fn test_box_fit_cover() { + let t = BoxFit::Cover.calculate_transform((100.0, 100.0), (200.0, 200.0)); + // Scaled to 200x200, so no translation + assert_eq!(t.matrix[0][2], 0.0); + assert_eq!(t.matrix[1][2], 0.0); + assert_eq!(t.matrix[0][0], 2.0); + assert_eq!(t.matrix[1][1], 2.0); + } + + #[test] + fn test_box_fit_contain_aspect_ratio() { + let t = BoxFit::Contain.calculate_transform((100.0, 200.0), (200.0, 200.0)); + assert_eq!(t.matrix[0][2], 50.0); + assert_eq!(t.matrix[1][2], 0.0); + assert_eq!(t.matrix[0][0], 1.0); + assert_eq!(t.matrix[1][1], 1.0); + } + + #[test] + fn test_box_fit_cover_aspect_ratio() { + let t = BoxFit::Cover.calculate_transform((100.0, 200.0), (200.0, 200.0)); + assert_eq!(t.matrix[0][2], 0.0); + assert_eq!(t.matrix[1][2], -100.0); + assert_eq!(t.matrix[0][0], 2.0); + assert_eq!(t.matrix[1][1], 2.0); + } +} diff --git a/crates/grida-math2/src/color.rs b/crates/grida-math2/src/color.rs new file mode 100644 index 0000000000..59958988eb --- /dev/null +++ b/crates/grida-math2/src/color.rs @@ -0,0 +1,130 @@ +use super::vector4::Vector4; + +/// The RGBA structure itself. The value range (0-1 or 0-255) +/// depends on the context using this color. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct TRGBA { + pub r: f32, + pub g: f32, + pub b: f32, + pub a: f32, +} + +/// Floating-Point RGBA (Normalized RGBA) used in graphics pipelines. +pub type RGBAf = TRGBA; + +/// 8-bit Integer RGBA (Standard RGBA) used in web graphics. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RGBA8888 { + /// Red channel value, between 0 and 255. + pub r: u8, + /// Green channel value, between 0 and 255. + pub g: u8, + /// Blue channel value, between 0 and 255. + pub b: u8, + /// Alpha channel value, between 0 and 1. + pub a: f32, +} + +/// Converts a HEX color string to an [`RGBA8888`]. +/// +/// Supports both short (`#RGB`) and long (`#RRGGBB`) HEX formats. +/// +/// # Parameters +/// - `hex`: HEX color string to convert. Must start with `#` and contain +/// 3 or 6 characters after the `#`. +/// +/// # Panics +/// If the input HEX string is invalid. +/// +/// # Examples +/// ``` +/// use math2::hex_to_rgba8888; +/// let c = hex_to_rgba8888("#F80"); +/// assert_eq!(c.r, 255); +/// assert_eq!(c.g, 136); +/// assert_eq!(c.b, 0); +/// assert_eq!(c.a, 1.0); +/// ``` +pub fn hex_to_rgba8888(hex: &str) -> RGBA8888 { + let hex = hex.trim_start_matches('#'); + let (r, g, b, a) = match hex.len() { + 3 => ( + u8::from_str_radix(&hex[0..1].repeat(2), 16).unwrap(), + u8::from_str_radix(&hex[1..2].repeat(2), 16).unwrap(), + u8::from_str_radix(&hex[2..3].repeat(2), 16).unwrap(), + 255u8, + ), + 6 => ( + u8::from_str_radix(&hex[0..2], 16).unwrap(), + u8::from_str_radix(&hex[2..4], 16).unwrap(), + u8::from_str_radix(&hex[4..6], 16).unwrap(), + 255u8, + ), + 8 => ( + u8::from_str_radix(&hex[0..2], 16).unwrap(), + u8::from_str_radix(&hex[2..4], 16).unwrap(), + u8::from_str_radix(&hex[4..6], 16).unwrap(), + u8::from_str_radix(&hex[6..8], 16).unwrap(), + ), + _ => panic!("Invalid hex format. Expected #RGB, #RRGGBB or #RRGGBBAA."), + }; + RGBA8888 { + r, + g, + b, + a: a as f32 / 255.0, + } +} + +/// Converts an [`RGBA8888`] color to a 4-component vector `[r, g, b, a]` +/// where the alpha is in the 0-255 range. +pub fn rgba_to_unit8_chunk(rgba: RGBA8888) -> Vector4 { + [ + rgba.r as f32, + rgba.g as f32, + rgba.b as f32, + (rgba.a * 255.0).round(), + ] +} + +/// Converts a normalized RGBA color to an 8-bit integer RGBA color. +/// +/// # Example +/// ``` +/// use math2::{rgbaf_to_rgba8888, RGBAf}; +/// let c = rgbaf_to_rgba8888(RGBAf { r: 1.0, g: 0.5, b: 0.0, a: 0.75 }); +/// assert_eq!(c.r, 255); +/// assert_eq!(c.g, 128); +/// assert_eq!(c.b, 0); +/// assert!((c.a - 0.75).abs() < 1e-6); +/// ``` +pub fn rgbaf_to_rgba8888(rgba: RGBAf) -> RGBA8888 { + RGBA8888 { + r: (rgba.r * 255.0).round() as u8, + g: (rgba.g * 255.0).round() as u8, + b: (rgba.b * 255.0).round() as u8, + a: rgba.a, + } +} + +/// Multiplies the alpha channel of the color by `alpha`. +pub fn rgbaf_multiply_alpha(color: TRGBA, alpha: f32) -> TRGBA { + TRGBA { + a: color.a * alpha, + ..color + } +} + +/// Returns a HEX color string (with leading `#`). +/// +/// # Example +/// ``` +/// use math2::{rgba8888_to_hex, RGBA8888}; +/// let hex = rgba8888_to_hex(RGBA8888 { r: 255, g: 255, b: 255, a: 1.0 }); +/// assert_eq!(hex, "#ffffffff"); +/// ``` +pub fn rgba8888_to_hex(color: RGBA8888) -> String { + let a = (color.a * 255.0).round() as u8; + format!("#{:02x}{:02x}{:02x}{:02x}", color.r, color.g, color.b, a) +} diff --git a/crates/grida-math2/src/compass.rs b/crates/grida-math2/src/compass.rs new file mode 100644 index 0000000000..c24e1925ec --- /dev/null +++ b/crates/grida-math2/src/compass.rs @@ -0,0 +1,27 @@ +use super::rect::{CardinalDirection, RectangleSide}; + +/// Returns the inverted cardinal direction. +pub fn invert_direction(dir: CardinalDirection) -> CardinalDirection { + match dir { + CardinalDirection::N => CardinalDirection::S, + CardinalDirection::E => CardinalDirection::W, + CardinalDirection::S => CardinalDirection::N, + CardinalDirection::W => CardinalDirection::E, + CardinalDirection::NE => CardinalDirection::SW, + CardinalDirection::SE => CardinalDirection::NW, + CardinalDirection::SW => CardinalDirection::NE, + CardinalDirection::NW => CardinalDirection::SE, + } +} + +/// Converts an orthogonal cardinal direction to a rectangle side. +/// Diagonal directions return `None`. +pub fn to_rectangle_side(dir: CardinalDirection) -> Option { + match dir { + CardinalDirection::N => Some(RectangleSide::Top), + CardinalDirection::E => Some(RectangleSide::Right), + CardinalDirection::S => Some(RectangleSide::Bottom), + CardinalDirection::W => Some(RectangleSide::Left), + _ => None, + } +} diff --git a/crates/grida-math2/src/delta.rs b/crates/grida-math2/src/delta.rs new file mode 100644 index 0000000000..b3afc07ae5 --- /dev/null +++ b/crates/grida-math2/src/delta.rs @@ -0,0 +1,23 @@ +/// Delta transformations for affine matrices. +/// +/// Provides helpers for projecting a scalar delta through a 2D affine +/// transformation matrix. +use super::{transform::AffineTransform, vector2::Axis}; + +/// Projects a scalar delta along a given axis through a 2D affine transform. +/// +/// # Parameters +/// - `offset`: The delta along the X or Y axis. +/// - `axis`: Which axis the delta corresponds to. +/// - `transform`: The 2×3 affine transform matrix. +/// +/// # Returns +/// The transformed scalar offset in surface space. +pub fn transform(offset: f32, axis: Axis, transform: &AffineTransform) -> f32 { + let i = match axis { + Axis::X => 0, + Axis::Y => 1, + }; + let row = transform.matrix[i]; + row[i] * offset + row[2] +} diff --git a/crates/grida-math2/src/layout.rs b/crates/grida-math2/src/layout.rs new file mode 100644 index 0000000000..d628a431d4 --- /dev/null +++ b/crates/grida-math2/src/layout.rs @@ -0,0 +1,157 @@ +use crate::utils::mean; +use crate::vector2::Axis; +use crate::{Rectangle, rect}; + +pub mod flex { + use super::*; + + /// Inferred main axis direction of a flex-like layout. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub enum AxisDirection { + Horizontal, + Vertical, + } + + /// Alignment of items along the main axis. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub enum MainAxisAlignment { + Start, + End, + Center, + } + + /// Alignment of items along the cross axis. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub enum CrossAxisAlignment { + Start, + End, + Center, + } + + /// Result returned by [`guess`]. + #[derive(Debug, Clone, PartialEq)] + pub struct Guessed { + pub orders: Vec, + pub direction: AxisDirection, + pub main_axis_alignment: MainAxisAlignment, + pub cross_axis_alignment: CrossAxisAlignment, + pub spacing: f32, + pub union: Rectangle, + } + + fn guess_cross_alignment(rects: &[Rectangle], cross: Axis) -> CrossAxisAlignment { + let starts: Vec = rects + .iter() + .map(|r| if cross == Axis::X { r.x } else { r.y }) + .collect(); + let centers: Vec = rects + .iter() + .map(|r| { + if cross == Axis::X { + r.x + r.width / 2.0 + } else { + r.y + r.height / 2.0 + } + }) + .collect(); + let ends: Vec = rects + .iter() + .map(|r| { + if cross == Axis::X { + r.x + r.width + } else { + r.y + r.height + } + }) + .collect(); + + let stdev = |vals: &[f32]| -> f32 { + if vals.len() < 2 { + return 0.0; + } + let m = mean(vals); + let var: f32 = vals.iter().map(|&v| (v - m).powi(2)).sum::() / vals.len() as f32; + var.sqrt() + }; + + let sd_start = stdev(&starts); + let sd_center = stdev(¢ers); + let sd_end = stdev(&ends); + + let min = sd_start.min(sd_center.min(sd_end)); + if (min - sd_start).abs() <= f32::EPSILON { + CrossAxisAlignment::Start + } else if (min - sd_center).abs() <= f32::EPSILON { + CrossAxisAlignment::Center + } else { + CrossAxisAlignment::End + } + } + + /// Guesses layout properties (direction, spacing and alignment) from a list + /// of bounding boxes. + /// + /// The algorithm roughly follows these steps: + /// 1. Sum gaps along both axes and pick the axis with the larger gap spread + /// as the main axis. + /// 2. Compute average spacing along the chosen axis. + /// 3. Sort rectangles on that axis to derive their order. + /// 4. Estimate cross axis alignment by comparing the variance of starts, + /// centers and ends. + pub fn guess(boundingboxes: &[Rectangle]) -> Guessed { + assert!( + !boundingboxes.is_empty(), + "At least one bounding box is required." + ); + + let unioned = rect::union(boundingboxes); + let width = unioned.width; + let height = unioned.height; + + let x_gaps = rect::get_gaps(boundingboxes, Axis::X); + let y_gaps = rect::get_gaps(boundingboxes, Axis::Y); + let total_x_gap: f32 = x_gaps.iter().sum(); + let total_y_gap: f32 = y_gaps.iter().sum(); + + let axis = if (total_x_gap - total_y_gap).abs() > 1.0 { + if total_x_gap > total_y_gap { + Axis::X + } else { + Axis::Y + } + } else { + if width >= height { Axis::X } else { Axis::Y } + }; + + let gaps = rect::get_gaps(boundingboxes, axis); + let spacing = if gaps.is_empty() { + 0.0 + } else { + mean(&gaps).max(0.0) + }; + + let mut orders: Vec<(usize, f32)> = boundingboxes + .iter() + .enumerate() + .map(|(i, r)| (i, if axis == Axis::X { r.x } else { r.y })) + .collect(); + orders.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap()); + let orders: Vec = orders.into_iter().map(|(i, _)| i).collect(); + + let cross = axis.counter(); + let cross_align = guess_cross_alignment(boundingboxes, cross); + + Guessed { + union: unioned, + direction: if axis == Axis::X { + AxisDirection::Horizontal + } else { + AxisDirection::Vertical + }, + spacing, + main_axis_alignment: MainAxisAlignment::Start, + cross_axis_alignment: cross_align, + orders, + } + } +} diff --git a/crates/grida-math2/src/lib.rs b/crates/grida-math2/src/lib.rs new file mode 100644 index 0000000000..5ee809c19e --- /dev/null +++ b/crates/grida-math2/src/lib.rs @@ -0,0 +1,79 @@ +pub mod align; +pub mod bezier; +pub mod box_fit; +pub mod color; +pub mod compass; +pub mod delta; +pub mod layout; +pub mod measurement; +pub mod packing; +pub mod range; +pub mod raster; +pub mod rect; +pub mod region; +pub mod snap; +pub mod transform; +pub mod ui; +pub mod utils; +pub mod vector2; +pub mod vector4; + +pub use align::{scalar as align_scalar, vector2 as align_vector2}; +pub use bezier::{ + CubicBezier, CubicBezierWithTangents, a2c as bezier_a2c, get_bbox as bezier_get_bbox, +}; +pub use color::{ + RGBA8888, RGBAf, TRGBA, hex_to_rgba8888, rgba_to_unit8_chunk, rgba8888_to_hex, + rgbaf_multiply_alpha, rgbaf_to_rgba8888, +}; +pub use compass::{invert_direction, to_rectangle_side}; +pub use delta::transform as delta_transform; +pub use layout::flex::{ + AxisDirection as FlexAxisDirection, CrossAxisAlignment as FlexCrossAxisAlignment, + Guessed as FlexGuessed, MainAxisAlignment as FlexMainAxisAlignment, guess as layout_flex_guess, +}; +pub use measurement::{Measurement, auxiliary_line_xylr, guide_line_xylr, measure}; +pub use packing::{ext::walk_to_fit as packing_walk_to_fit, fit as packing_fit}; +pub use range::{ + Range, UniformGapGroup, from_rectangle, group_ranges_by_uniform_gap, length as range_length, + mean as range_mean, to_3points_chunk, +}; +pub use raster::{ + Bitmap, bresenham as raster_bresenham, circle as raster_circle, ellipse as raster_ellipse, + floodfill as raster_floodfill, fract, gaussian as raster_gaussian, noise, pad as raster_pad, + pascaltriangle as raster_pascaltriangle, rectangle as raster_rectangle, + resize as raster_resize, scale as raster_scale, smoothstep as raster_smoothstep, + tile as raster_tile, +}; +pub use rect::boolean::subtract as rect_boolean_subtract; +pub use rect::{ + AlignKind, Alignment, CardinalDirection, Rect9Points, Rectangle, RectangleSide, Sides, + align as rect_align, align_a as rect_align_a, aspect_ratio, axis_projection_intersection, + contains, contains_point, distribute_evenly as rect_distribute_evenly, from_points, + get_cardinal_point, get_center, get_gaps as rect_get_gaps, get_relative_transform, + get_scale_factors, get_uniform_gap as rect_get_uniform_gap, inset as rect_inset, intersection, + intersects, is_identical as rect_identical, is_uniform as rect_uniform, offset, + pad as rect_pad, positive as rect_positive, quantize as rect_quantize, rotate as rect_rotate, + tile as rect_tile, to_9points, to_9points_chunk, transform as rect_transform, union, +}; +pub use region::{Region, difference as region_difference, subtract as region_subtract}; +pub use snap::axis::{ + AxisAlignedPoint, Movement, Snap1DResult, Snap2DAxisAlignedResult, Snap2DAxisConfig, + axis_locked_by_dominance, normalize as movement_normalize, snap1d, snap2d_axis_aligned, +}; +pub use snap::canvas::{Guide as SnapGuide, SnapToCanvasResult, snap_to_canvas_geometry}; +pub use snap::spacing::{ + DistributionGeometry1D, ProjectionPoint as SnapProjectionPoint, plot_distribution_geometry, +}; +pub use snap::viewport::{ + Margins as ViewportMargins, transform_to_fit as viewport_transform_to_fit, +}; +pub use ui::{ + Line as UiLine, Point as UiPoint, Rule as UiRule, format_number, normalize_line, + transform_line, transform_point, +}; +pub use utils::{ + angle_to_axis, clamp, combinations, is_uniform, mean, nearest, permutations, powerset, + principal_angle, quantize, +}; +pub use vector4::{Vector4, identical as vector4_identical}; diff --git a/crates/grida-math2/src/measurement.rs b/crates/grida-math2/src/measurement.rs new file mode 100644 index 0000000000..043c924141 --- /dev/null +++ b/crates/grida-math2/src/measurement.rs @@ -0,0 +1,197 @@ +use crate::{Rectangle, RectangleSide, rect, vector2::Vector2}; + +/// Result returned by [`measure`]. +/// +/// `a` and `b` are the input rectangles. `box_rect` is the rectangle used as +/// the reference when computing the spacing values. `distance` contains the +/// offsets to the nearest top, right, bottom and left edges in that order. + +#[derive(Debug, Clone, PartialEq)] +pub struct Measurement { + pub a: Rectangle, + pub b: Rectangle, + pub box_rect: Rectangle, + /// top, right, bottom, left distances + pub distance: [f32; 4], +} + +/// Calculates the spacing between two rectangles along with the reference box. +/// +/// See the module-level documentation for the exact behaviour. Returns `None` +/// when `a` and `b` are identical. +pub fn measure(a: Rectangle, b: Rectangle) -> Option { + if a == b { + return None; + } + let intersection = rect::intersection(&a, &b); + + if intersection.is_none() { + let spacing = calculate_non_intersecting_spacing(&a, &b); + return Some(Measurement { + a, + b, + box_rect: a, + distance: spacing, + }); + } + + let intersection = intersection.unwrap(); + + if b.contains(&a) { + return Some(Measurement { + a, + b, + box_rect: a, + distance: calculate_container_spacing(&b, &a), + }); + } + + if a.contains(&b) { + return Some(Measurement { + a, + b, + box_rect: b, + distance: calculate_container_spacing(&a, &b), + }); + } + + let spacing = calculate_intersecting_spacing(&a, &b, &intersection); + Some(Measurement { + a, + b, + box_rect: intersection, + distance: spacing, + }) +} + +fn calculate_intersecting_spacing(a: &Rectangle, b: &Rectangle, inter: &Rectangle) -> [f32; 4] { + [ + (inter.y - a.y.min(b.y)).abs(), + ((a.x + a.width).max(b.x + b.width) - (inter.x + inter.width)).abs(), + ((a.y + a.height).max(b.y + b.height) - (inter.y + inter.height)).abs(), + (inter.x - a.x.min(b.x)).abs(), + ] +} + +fn calculate_non_intersecting_spacing(a: &Rectangle, b: &Rectangle) -> [f32; 4] { + let mut top = 0.0; + let mut right = 0.0; + let mut bottom = 0.0; + let mut left = 0.0; + if a.x + a.width <= b.x { + right = b.x - (a.x + a.width); + } else if b.x + b.width <= a.x { + left = a.x - (b.x + b.width); + } + if a.y + a.height <= b.y { + bottom = b.y - (a.y + a.height); + } else if b.y + b.height <= a.y { + top = a.y - (b.y + b.height); + } + [top, right, bottom, left] +} + +fn calculate_container_spacing(outer: &Rectangle, inner: &Rectangle) -> [f32; 4] { + [ + (outer.y - inner.y).abs(), + (outer.x + outer.width - (inner.x + inner.width)).abs(), + (outer.y + outer.height - (inner.y + inner.height)).abs(), + (outer.x - inner.x).abs(), + ] +} + +type LineXYXYLR = [f32; 6]; + +/// Generates guide line coordinates from the center of `rect` toward a side. +/// +/// The returned array is `[x1, y1, x2, y2, length, rotation]` scaled by +/// `zoom` where `(x1, y1)` is the anchor on the rectangle and `(x2, y2)` the +/// outer end. +pub fn guide_line_xylr(rect: Rectangle, side: RectangleSide, length: f32, zoom: f32) -> LineXYXYLR { + let Rectangle { + x, + y, + width, + height, + } = rect; + let mid_x = x + width / 2.0; + let mid_y = y + height / 2.0; + let scaled = length * zoom; + + let (x1, y1, x2, y2, rotation) = match side { + RectangleSide::Top => { + let x1 = mid_x * zoom; + let y1 = y * zoom; + (x1, y1, x1, y1 - scaled, 180.0) + } + RectangleSide::Right => { + let x1 = (x + width) * zoom; + let y1 = mid_y * zoom; + (x1, y1, x1 + scaled, y1, 270.0) + } + RectangleSide::Bottom => { + let x1 = mid_x * zoom; + let y1 = (y + height) * zoom; + (x1, y1, x1, y1 + scaled, 0.0) + } + RectangleSide::Left => { + let x1 = x * zoom; + let y1 = mid_y * zoom; + (x1, y1, x1 - scaled, y1, 90.0) + } + }; + [x1, y1, x2, y2, scaled, rotation] +} + +/// Generates an auxiliary guide line from `point` toward the closest side of +/// `rect`. +/// +/// Returns `[x1, y1, x2, y2, length, rotation]` scaled by `zoom`. When the +/// point lies inside the rectangle, `x2`/`y2` are `NaN` and length is zero. +pub fn auxiliary_line_xylr( + point: Vector2, + rect: Rectangle, + side: RectangleSide, + zoom: f32, +) -> LineXYXYLR { + let [px, py] = point; + let Rectangle { + x, + y, + width, + height, + } = rect; + let rect_right = x + width; + let rect_bottom = y + height; + + let x1 = px * zoom; + let y1 = py * zoom; + let (x2, y2, length, rotation); + + if rect::contains_point(&rect, point) { + return [x1, y1, f32::NAN, f32::NAN, 0.0, 0.0]; + } + + match side { + RectangleSide::Top | RectangleSide::Bottom => { + if px < x { + let length_v = (x - px) * zoom; + (x2, y2, length, rotation) = (x1 + length_v, y1, length_v, -90.0); + } else { + let length_v = (px - rect_right) * zoom; + (x2, y2, length, rotation) = (x1 - length_v, y1, length_v, 90.0); + } + } + RectangleSide::Left | RectangleSide::Right => { + if py > rect_bottom { + let length_v = (py - rect_bottom) * zoom; + (x2, y2, length, rotation) = (x1, y1 - length_v, length_v, 180.0); + } else { + let length_v = (y - py) * zoom; + (x2, y2, length, rotation) = (x1, y1 + length_v, length_v, 0.0); + } + } + } + + [x1, y1, x2, y2, length, rotation] +} diff --git a/crates/grida-math2/src/packing.rs b/crates/grida-math2/src/packing.rs new file mode 100644 index 0000000000..ef9f691046 --- /dev/null +++ b/crates/grida-math2/src/packing.rs @@ -0,0 +1,79 @@ +//! Rectangle packing utilities for layout optimization. +//! +//! This module implements a simple variation of the MaxRects algorithm to +//! compute valid placements for rectangular agents within a bounded domain. +//! It also provides a helper that "walks" outward when no placement can be +//! found inside the original view. + +use crate::rect::{self, Rectangle}; + +/// Calculates the next viable placement for a rectangular agent within the +/// given `view` rectangle, avoiding `anchors` that represent occupied regions. +/// +/// The algorithm iteratively subtracts each anchor from the free-space set and +/// selects the smallest lexicographical free region that can contain the agent. +/// Returns `None` if no placement is found. +pub fn fit(view: Rectangle, agent: (f32, f32), anchors: &[Rectangle]) -> Option { + let mut free_regions = vec![view]; + for &anchor in anchors { + let mut updated = Vec::new(); + for region in &free_regions { + updated.extend(rect::boolean::subtract(*region, anchor)); + } + free_regions = updated; + } + + free_regions.retain(|r| r.width >= agent.0 && r.height >= agent.1); + if free_regions.is_empty() { + return None; + } + + free_regions.sort_by(|a, b| { + a.y.partial_cmp(&b.y) + .unwrap() + .then(a.x.partial_cmp(&b.x).unwrap()) + }); + let chosen = free_regions[0]; + Some(Rectangle { + x: chosen.x, + y: chosen.y, + width: agent.0, + height: agent.1, + }) +} + +pub mod ext { + use super::*; + + /// Attempts to find a placement by gradually expanding the search region + /// outward from the view's top-left corner. + /// + /// This function is useful when `fit` fails to locate a spot inside the + /// initial view. It walks in the positive right/down directions on a grid + /// until a non-overlapping position is found. + pub fn walk_to_fit(view: Rectangle, agent: (f32, f32), anchors: &[Rectangle]) -> Rectangle { + if let Some(r) = super::fit(view, agent, anchors) { + return r; + } + + let step = (agent.0.min(agent.1) / 2.0).max(1.0); + let mut radius = step; + loop { + let max = radius as i32; + for dy in (0..=max).map(|v| v as f32).step_by(step as usize) { + for dx in (0..=max).map(|v| v as f32).step_by(step as usize) { + let candidate = Rectangle { + x: view.x + dx, + y: view.y + dy, + width: agent.0, + height: agent.1, + }; + if !anchors.iter().any(|a| rect::intersects(&candidate, a)) { + return candidate; + } + } + } + radius += step; + } + } +} diff --git a/crates/grida-math2/src/range.rs b/crates/grida-math2/src/range.rs new file mode 100644 index 0000000000..1edd53fa76 --- /dev/null +++ b/crates/grida-math2/src/range.rs @@ -0,0 +1,112 @@ +use super::rect::Rectangle; +use super::utils::{is_uniform, mean as mean_scalar, powerset}; +use super::vector2::Axis; + +/// A 1D range represented as `[start, end]` where start <= end. +pub type Range = [f32; 2]; + +/// Returns the average center of multiple ranges. +pub fn mean(ranges: &[Range]) -> f32 { + let centers: Vec = ranges.iter().map(|r| (r[0] + r[1]) / 2.0).collect(); + mean_scalar(¢ers) +} + +/// Creates a range from a rectangle along the given axis. +pub fn from_rectangle(rect: &Rectangle, axis: Axis) -> Range { + match axis { + Axis::X => [rect.x, rect.x + rect.width], + Axis::Y => [rect.y, rect.y + rect.height], + } +} + +/// Length of the range. +pub fn length(range: Range) -> f32 { + range[1] - range[0] +} + +/// Returns `[start, mid, end]` of the range. +pub fn to_3points_chunk(range: Range) -> [f32; 3] { + [range[0], (range[0] + range[1]) / 2.0, range[1]] +} + +/// Result of grouping ranges by uniform gaps. +#[derive(Debug, Clone, PartialEq)] +pub struct UniformGapGroup { + pub loop_indices: Vec, + pub min: f32, + pub max: f32, + pub gap: f32, +} + +/// Groups ranges whose gaps are uniform within `tolerance`. +/// +/// `k` limits the subset size examined (`None` for all combinations). The +/// returned list contains the indices of the ranges in each group, the +/// minimum/maximum positions and the uniform gap value. +/// +/// This uses a power-set search and thus has exponential complexity. +/// +/// # Example +/// ```rust +/// use math2::group_ranges_by_uniform_gap; +/// let ranges = vec![[0.0,10.0],[15.0,25.0],[30.0,40.0]]; +/// let groups = group_ranges_by_uniform_gap(&ranges, None, 0.0); +/// assert!(!groups.is_empty()); +/// ``` +pub fn group_ranges_by_uniform_gap( + ranges: &[Range], + k: Option, + tolerance: f32, +) -> Vec { + let subsets = powerset(ranges, k); + let mut result = Vec::new(); + + 'outer: for subset in subsets { + if subset.is_empty() { + continue; + } + if subset.len() == 1 { + let idx = ranges.iter().position(|r| r == &subset[0]).unwrap(); + result.push(UniformGapGroup { + loop_indices: vec![idx], + min: subset[0][0], + max: subset[0][1], + gap: 0.0, + }); + continue; + } + + let subset_indices: Vec = ranges + .iter() + .enumerate() + .filter_map(|(i, r)| if subset.contains(r) { Some(i) } else { None }) + .collect(); + + let mut sorted = subset_indices.clone(); + sorted.sort_by(|&a, &b| ranges[a][0].partial_cmp(&ranges[b][0]).unwrap()); + + let mut distances = Vec::new(); + for i in 1..sorted.len() { + let p0 = ranges[sorted[i - 1]]; + let p1 = ranges[sorted[i]]; + let dist = p1[0] - p0[1]; + if dist < 0.0 { + continue 'outer; + } + distances.push(dist); + } + + if is_uniform(&distances, tolerance) { + let starts: Vec = sorted.iter().map(|&i| ranges[i][0]).collect(); + let ends: Vec = sorted.iter().map(|&i| ranges[i][1]).collect(); + result.push(UniformGapGroup { + loop_indices: sorted, + min: starts.iter().cloned().fold(f32::INFINITY, f32::min), + max: ends.iter().cloned().fold(f32::NEG_INFINITY, f32::max), + gap: distances.get(0).cloned().unwrap_or(0.0), + }); + } + } + + result +} diff --git a/crates/grida-math2/src/raster.rs b/crates/grida-math2/src/raster.rs new file mode 100644 index 0000000000..174674127a --- /dev/null +++ b/crates/grida-math2/src/raster.rs @@ -0,0 +1,336 @@ +use super::{rect::Rectangle, vector2::Vector2}; + +/// Returns the fractional part of a number. +/// +/// # Arguments +/// * `x` - The input value. +/// +/// # Example +/// ```rust +/// # use math2::fract; +/// let frac = fract(3.14); +/// assert!((frac - 0.14).abs() < 1e-2); +/// ``` +pub fn fract(x: f32) -> f32 { + x - x.floor() +} + +/// Computes a pseudo-random noise value for the given 2D coordinate. +/// +/// This follows a GLSL-style hash using "magic" constants to produce a +/// nicely distributed value. While fast for grain generation, it's not +/// intended for high quality noise. +/// +/// The calculation performed is: +/// `noise(x, y) = fract(sin(x * 12.9898 + y * 78.233) * 43758.5453)`. +/// +/// # Parameters +/// - `x`: X coordinate. +/// - `y`: Y coordinate. +/// +/// # Returns +/// A pseudo-random value in `[0, 1]`. +/// +/// # Example +/// ```rust +/// # use math2::noise; +/// let v = noise(12.34, 56.78); +/// assert!(v >= 0.0 && v <= 1.0); +/// ``` +pub fn noise(x: f32, y: f32) -> f32 { + fract(((x * 12.9898 + y * 78.233).sin()) * 43758.5453) +} + +/// Returns all integer pixel coordinates along a straight line between +/// `a` and `b` using Bresenham's algorithm. +/// +/// # Parameters +/// - `a`: Start point in pixel coordinates. +/// - `b`: End point in pixel coordinates. +/// +/// # Example +/// ```rust +/// # use math2::raster_bresenham; +/// let pts = raster_bresenham([10.0, 10.0], [15.0, 20.0]); +/// assert_eq!(pts.first(), Some(&[10.0, 10.0])); +/// ``` +pub fn bresenham(a: Vector2, b: Vector2) -> Vec { + let (mut x0, mut y0) = (a[0] as i32, a[1] as i32); + let (x1, y1) = (b[0] as i32, b[1] as i32); + let mut pixels = Vec::new(); + + let dx = (x1 - x0).abs(); + let sx = if x0 < x1 { 1 } else { -1 }; + let dy = -(y1 - y0).abs(); + let sy = if y0 < y1 { 1 } else { -1 }; + let mut err = dx + dy; + + loop { + pixels.push([x0 as f32, y0 as f32]); + if x0 == x1 && y0 == y1 { + break; + } + let e2 = 2 * err; + if e2 >= dy { + err += dy; + x0 += sx; + } + if e2 <= dx { + err += dx; + y0 += sy; + } + } + + pixels +} + +/// Generates all integer pixel coordinates contained within a rectangle. +/// +/// The rectangle is defined by its top-left corner and dimensions, and +/// the output includes all pixels from `x` to `x + width` and `y` to +/// `y + height` inclusively. +/// +/// # Example +/// ```rust +/// # use math2::{Rectangle, raster_rectangle}; +/// let rect = Rectangle { x: 40.0, y: 35.0, width: 20.0, height: 30.0 }; +/// let points = raster_rectangle(&rect); +/// assert!(points.contains(&[40.0, 35.0])); +/// ``` +pub fn rectangle(rect: &Rectangle) -> Vec { + let start_x = rect.x.ceil() as i32; + let end_x = (rect.x + rect.width).floor() as i32; + let start_y = rect.y.ceil() as i32; + let end_y = (rect.y + rect.height).floor() as i32; + let mut points = Vec::new(); + for y in start_y..=end_y { + for x in start_x..=end_x { + points.push([x as f32, y as f32]); + } + } + points +} + +/// A raw bitmap represented by width, height and RGBA data. +#[derive(Clone, Debug, PartialEq)] +pub struct Bitmap { + pub width: usize, + pub height: usize, + pub data: Vec, +} + +/// Tiles a source bitmap to cover the given `width` and `height`. +pub fn tile(source: &Bitmap, width: usize, height: usize) -> Bitmap { + let mut out = vec![0u8; width * height * 4]; + for y in 0..height { + for x in 0..width { + let sx = x % source.width; + let sy = y % source.height; + let src = (sy * source.width + sx) * 4; + let dst = (y * width + x) * 4; + out[dst..dst + 4].copy_from_slice(&source.data[src..src + 4]); + } + } + Bitmap { + width, + height, + data: out, + } +} + +/// Scales a bitmap by `[factor_x, factor_y]` using nearest neighbour sampling. +pub fn scale(bitmap: &Bitmap, factor: Vector2) -> Bitmap { + let (factor_x, factor_y) = (factor[0], factor[1]); + assert!(factor_x > 0.0 && factor_y > 0.0, "factors must be positive"); + let width = ((bitmap.width as f32 * factor_x).floor().max(1.0)) as usize; + let height = ((bitmap.height as f32 * factor_y).floor().max(1.0)) as usize; + let mut out = vec![0u8; width * height * 4]; + for y in 0..height { + for x in 0..width { + let sx = ((x as f32) / factor_x).floor() as usize; + let sy = ((y as f32) / factor_y).floor() as usize; + let src = (sy * bitmap.width + sx) * 4; + let dst = (y * width + x) * 4; + out[dst..dst + 4].copy_from_slice(&bitmap.data[src..src + 4]); + } + } + Bitmap { + width, + height, + data: out, + } +} + +/// Resizes a bitmap to the specified `[width, height]`. +pub fn resize(bitmap: &Bitmap, dst: Vector2) -> Bitmap { + let (w2, h2) = (dst[0] as f32, dst[1] as f32); + let fx = w2 / bitmap.width as f32; + let fy = h2 / bitmap.height as f32; + scale(bitmap, [fx, fy]) +} + +/// Pads a bitmap to the given `[width, height]` filling empty space with `bg`. +pub fn pad(bitmap: &Bitmap, dst: Vector2, bg: super::vector4::Vector4) -> Bitmap { + let width = dst[0] as usize; + let height = dst[1] as usize; + let mut out = vec![0u8; width * height * 4]; + for i in 0..width * height { + let idx = i * 4; + out[idx] = bg[0] as u8; + out[idx + 1] = bg[1] as u8; + out[idx + 2] = bg[2] as u8; + out[idx + 3] = bg[3] as u8; + } + let offset_x = ((width as i32 - bitmap.width as i32) / 2) as isize; + let offset_y = ((height as i32 - bitmap.height as i32) / 2) as isize; + for y in 0..bitmap.height { + for x in 0..bitmap.width { + let dst_x = x as isize + offset_x; + let dst_y = y as isize + offset_y; + if dst_x < 0 || dst_y < 0 || dst_x >= width as isize || dst_y >= height as isize { + continue; + } + let src = (y * bitmap.width + x) * 4; + let dst = (dst_y as usize * width + dst_x as usize) * 4; + out[dst..dst + 4].copy_from_slice(&bitmap.data[src..src + 4]); + } + } + Bitmap { + width, + height, + data: out, + } +} + +/// Flood fills starting at `pos` with `fill` color. +pub fn floodfill(bitmap: &mut Bitmap, pos: Vector2, fill: super::vector4::Vector4) { + let x = pos[0] as i32; + let y = pos[1] as i32; + if x < 0 || y < 0 || x >= bitmap.width as i32 || y >= bitmap.height as i32 { + return; + } + let idx = (y as usize * bitmap.width + x as usize) * 4; + let target = [ + bitmap.data[idx], + bitmap.data[idx + 1], + bitmap.data[idx + 2], + bitmap.data[idx + 3], + ]; + if target == [fill[0] as u8, fill[1] as u8, fill[2] as u8, fill[3] as u8] { + return; + } + let mut stack = vec![(x, y)]; + while let Some((cx, cy)) = stack.pop() { + if cx < 0 || cy < 0 || cx >= bitmap.width as i32 || cy >= bitmap.height as i32 { + continue; + } + let i = (cy as usize * bitmap.width + cx as usize) * 4; + let cur = [ + bitmap.data[i], + bitmap.data[i + 1], + bitmap.data[i + 2], + bitmap.data[i + 3], + ]; + if cur != target { + continue; + } + bitmap.data[i..i + 4].copy_from_slice(&[ + fill[0] as u8, + fill[1] as u8, + fill[2] as u8, + fill[3] as u8, + ]); + stack.push((cx - 1, cy)); + stack.push((cx + 1, cy)); + stack.push((cx, cy - 1)); + stack.push((cx, cy + 1)); + } +} + +/// Generates integer pixel coordinates within a circle with optional clipping. +pub fn circle(center: Vector2, radius: f32, clip: Option) -> Vec { + let (cx, cy) = (center[0], center[1]); + let r_sq = radius * radius; + let mut results = Vec::new(); + let (min_x, min_y, max_x, max_y) = if let Some(c) = clip { + (c.x, c.y, c.x + c.width - 1.0, c.y + c.height - 1.0) + } else { + ( + f32::NEG_INFINITY, + f32::NEG_INFINITY, + f32::INFINITY, + f32::INFINITY, + ) + }; + let y_start = (cy - radius).floor() as i32; + let y_end = (cy + radius).floor() as i32; + for y in y_start..=y_end { + let dy = y as f32 - cy; + let span = (r_sq - dy * dy).sqrt(); + if span.is_nan() { + continue; + } + let left = (cx - span).floor() as i32; + let right = (cx + span).floor() as i32; + for x in left..=right { + let xf = x as f32; + let yf = y as f32; + if xf < min_x || xf > max_x || yf < min_y || yf > max_y { + continue; + } + results.push([xf, yf]); + } + } + results +} + +/// Returns pixel coordinates for an ellipse. +pub fn ellipse(center: Vector2, radius: Vector2) -> Vec { + let (cx, cy) = (center[0], center[1]); + let (rx, ry) = (radius[0], radius[1]); + let mut pts = Vec::new(); + let start_x = (cx - rx).ceil() as i32; + let end_x = (cx + rx).floor() as i32; + let start_y = (cy - ry).ceil() as i32; + let end_y = (cy + ry).floor() as i32; + for y in start_y..=end_y { + for x in start_x..=end_x { + let dx = x as f32 - cx; + let dy = y as f32 - cy; + if (dx * dx) / (rx * rx) + (dy * dy) / (ry * ry) <= 1.0 { + pts.push([x as f32, y as f32]); + } + } + } + pts +} + +/// Gaussian weight with `hardness` controlling the falloff steepness. +pub fn gaussian(norm_dist: f32, hardness: f32) -> f32 { + let k_hard = 2.0_f32; + let k_soft = 10.0_f32; + let k = hardness * k_hard + (1.0 - hardness) * k_soft; + (-k * norm_dist * norm_dist).exp() +} + +/// Generalized smoothstep function of order `n`. +pub fn smoothstep(n: i32, mut x: f32) -> f32 { + use super::utils::clamp; + x = clamp(x, 0.0, 1.0); + let mut result = 0.0; + for i in 0..=n { + result += pascaltriangle(-(n as f32) - 1.0, i) + * pascaltriangle(2.0 * n as f32 + 1.0, n - i) + * x.powf(n as f32 + i as f32 + 1.0); + } + result +} + +/// Binomial coefficient using generalized Pascal's triangle. +pub fn pascaltriangle(a: f32, b: i32) -> f32 { + let mut result = 1.0; + for i in 0..b { + result *= (a - i as f32) / (i as f32 + 1.0); + } + result +} diff --git a/crates/grida-math2/src/rect.rs b/crates/grida-math2/src/rect.rs new file mode 100644 index 0000000000..221ae01181 --- /dev/null +++ b/crates/grida-math2/src/rect.rs @@ -0,0 +1,786 @@ +use std::fmt; + +use super::vector2::Vector2; + +/// Represents a side of a rectangle. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RectangleSide { + Top, + Right, + Bottom, + Left, +} + +/// Cardinal directions including diagonals. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CardinalDirection { + N, + E, + S, + W, + NE, + SE, + SW, + NW, +} + +/// A rectangle defined by its top-left corner `(x, y)` and `width` and `height`. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Rectangle { + pub x: f32, + pub y: f32, + pub width: f32, + pub height: f32, +} + +impl Rectangle { + pub fn empty() -> Self { + Rectangle { + x: 0.0, + y: 0.0, + width: 0.0, + height: 0.0, + } + } + + /// Returns the center point of the rectangle. + pub fn center(&self) -> Vector2 { + [self.x + self.width / 2.0, self.y + self.height / 2.0] + } + + /// Returns a new rectangle translated by the given vector. + pub fn translate(&self, delta: Vector2) -> Self { + Self { + x: self.x + delta[0], + y: self.y + delta[1], + ..*self + } + } + + /// Scales the rectangle relative to the given origin. + pub fn scale(&self, origin: Vector2, scale: Vector2) -> Self { + let [sx, sy] = scale; + let [ox, oy] = origin; + Self { + x: ox + (self.x - ox) * sx, + y: oy + (self.y - oy) * sy, + width: self.width * sx, + height: self.height * sy, + } + } + + /// Returns the dimension (`width` or `height`) for the given axis. + pub fn axis_dimension(&self, axis: super::vector2::Axis) -> f32 { + match axis { + super::vector2::Axis::X => self.width, + super::vector2::Axis::Y => self.height, + } + } + + /// Returns true if `self` fully contains `other`. + pub fn contains(&self, other: &Rectangle) -> bool { + self.x <= other.x + && self.y <= other.y + && self.x + self.width >= other.x + other.width + && self.y + self.height >= other.y + other.height + } + + /// Returns true if the point is inside the rectangle (inclusive). + pub fn contains_point(&self, point: Vector2) -> bool { + let [px, py] = point; + px >= self.x && px <= self.x + self.width && py >= self.y && py <= self.y + self.height + } + + /// Returns the signed offset from the point to the nearest edge. + pub fn offset_to(&self, point: Vector2) -> Vector2 { + let clamped_x = point[0].max(self.x).min(self.x + self.width); + let clamped_y = point[1].max(self.y).min(self.y + self.height); + [point[0] - clamped_x, point[1] - clamped_y] + } + + /// Returns `true` if two rectangles intersect or touch at the edges. + pub fn intersects(&self, other: &Rectangle) -> bool { + let a_right = self.x + self.width; + let a_bottom = self.y + self.height; + let b_right = other.x + other.width; + let b_bottom = other.y + other.height; + + !(self.x > b_right || self.y > b_bottom || a_right < other.x || a_bottom < other.y) + } + + /// Returns the intersection of two rectangles, or `None` if they do not overlap. + pub fn intersection(&self, other: &Rectangle) -> Option { + let x1 = self.x.max(other.x); + let y1 = self.y.max(other.y); + let x2 = (self.x + self.width).min(other.x + other.width); + let y2 = (self.y + self.height).min(other.y + other.height); + + if x2 <= x1 || y2 <= y1 { + return None; + } + + Some(Rectangle { + x: x1, + y: y1, + width: x2 - x1, + height: y2 - y1, + }) + } + + /// Subtracts `other` from this rectangle, returning the remaining subregions. + pub fn subtract(&self, other: Rectangle) -> Vec { + boolean::subtract(*self, other) + } +} + +/// Computes the smallest rectangle that encloses all provided points. +pub fn from_points(points: &[Vector2]) -> Rectangle { + assert!(!points.is_empty(), "at least one point is required"); + let mut min_x = f32::INFINITY; + let mut min_y = f32::INFINITY; + let mut max_x = f32::NEG_INFINITY; + let mut max_y = f32::NEG_INFINITY; + for &[x, y] in points { + if x < min_x { + min_x = x; + } + if y < min_y { + min_y = y; + } + if x > max_x { + max_x = x; + } + if y > max_y { + max_y = y; + } + } + Rectangle { + x: min_x, + y: min_y, + width: max_x - min_x, + height: max_y - min_y, + } +} + +impl fmt::Display for Rectangle { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "x: {}, y: {}, width: {}, height: {}", + self.x, self.y, self.width, self.height + ) + } +} + +/// Returns an object containing the nine control points of a rectangle. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Rect9Points { + pub top_left: Vector2, + pub top_right: Vector2, + pub bottom_right: Vector2, + pub bottom_left: Vector2, + pub top_center: Vector2, + pub right_center: Vector2, + pub bottom_center: Vector2, + pub left_center: Vector2, + pub center: Vector2, +} + +/// Computes the nine control points of the rectangle. +pub fn to_9points(rect: &Rectangle) -> Rect9Points { + let Rectangle { + x, + y, + width, + height, + } = *rect; + let center_x = x + width / 2.0; + let center_y = y + height / 2.0; + Rect9Points { + top_left: [x, y], + top_right: [x + width, y], + bottom_right: [x + width, y + height], + bottom_left: [x, y + height], + top_center: [center_x, y], + right_center: [x + width, center_y], + bottom_center: [center_x, y + height], + left_center: [x, center_y], + center: [center_x, center_y], + } +} + +/// Same as [`to_9points`] but returns the points in an array ordered as: +/// topLeft, topRight, bottomRight, bottomLeft, topCenter, rightCenter, +/// bottomCenter, leftCenter, center. +pub fn to_9points_chunk(rect: &Rectangle) -> [Vector2; 9] { + let p = to_9points(rect); + [ + p.top_left, + p.top_right, + p.bottom_right, + p.bottom_left, + p.top_center, + p.right_center, + p.bottom_center, + p.left_center, + p.center, + ] +} + +/// Returns true if rectangle `a` fully contains rectangle `b`. +pub fn contains(a: &Rectangle, b: &Rectangle) -> bool { + a.contains(b) +} + +/// Returns true if the point is inside the rectangle (inclusive). +pub fn contains_point(rect: &Rectangle, point: Vector2) -> bool { + rect.contains_point(point) +} + +/// Returns the signed offset from the point to the nearest edge of the rectangle. +pub fn offset(rect: &Rectangle, point: Vector2) -> Vector2 { + rect.offset_to(point) +} + +/// Returns `true` if two rectangles intersect or touch at the edges. +pub fn intersects(a: &Rectangle, b: &Rectangle) -> bool { + a.intersects(b) +} + +/// Returns the intersection of two rectangles, or `None` if they do not overlap. +pub fn intersection(a: &Rectangle, b: &Rectangle) -> Option { + a.intersection(b) +} + +/// Computes the bounding rectangle of all input rectangles. +pub fn union(rects: &[Rectangle]) -> Rectangle { + assert!(!rects.is_empty(), "rectangles array cannot be empty"); + let mut min_x = f32::INFINITY; + let mut min_y = f32::INFINITY; + let mut max_x = f32::NEG_INFINITY; + let mut max_y = f32::NEG_INFINITY; + for r in rects { + if r.x < min_x { + min_x = r.x; + } + if r.y < min_y { + min_y = r.y; + } + if r.x + r.width > max_x { + max_x = r.x + r.width; + } + if r.y + r.height > max_y { + max_y = r.y + r.height; + } + } + Rectangle { + x: min_x, + y: min_y, + width: max_x - min_x, + height: max_y - min_y, + } +} + +/// Tiles the rectangle into a grid of equally sized rectangles. +/// +/// The `size` parameter specifies the number of `(columns, rows)`. +/// Each must evenly divide the rectangle's width and height respectively. +/// +/// # Panics +/// +/// Panics if the provided column or row count is zero or does not +/// evenly divide the rectangle dimensions. +pub fn tile(rect: Rectangle, size: (usize, usize)) -> Vec { + let (cols, rows) = size; + assert!(cols > 0 && rows > 0, "size must be greater than zero"); + + let tile_w = rect.width / cols as f32; + let tile_h = rect.height / rows as f32; + + assert!(tile_w.fract() == 0.0, "columns must evenly divide width"); + assert!(tile_h.fract() == 0.0, "rows must evenly divide height"); + + let mut out = Vec::with_capacity(cols * rows); + for r in 0..rows { + for c in 0..cols { + out.push(Rectangle { + x: rect.x + c as f32 * tile_w, + y: rect.y + r as f32 * tile_h, + width: tile_w, + height: tile_h, + }); + } + } + out +} + +/// Boolean operations on rectangles. +pub mod boolean { + use super::{Rectangle, intersection}; + + /// Subtracts rectangle `b` from rectangle `a`, returning the remaining subregions. + pub fn subtract(a: Rectangle, b: Rectangle) -> Vec { + let inter = match intersection(&a, &b) { + Some(i) if i.width > 0.0 && i.height > 0.0 => i, + _ => return vec![a], + }; + + let mut result = Vec::new(); + + // Top region + if a.y < inter.y { + result.push(Rectangle { + x: a.x, + y: a.y, + width: a.width, + height: inter.y - a.y, + }); + } + + // Bottom region + if a.y + a.height > inter.y + inter.height { + result.push(Rectangle { + x: a.x, + y: inter.y + inter.height, + width: a.width, + height: a.y + a.height - (inter.y + inter.height), + }); + } + + // Left region + if a.x < inter.x { + result.push(Rectangle { + x: a.x, + y: inter.y, + width: inter.x - a.x, + height: inter.height, + }); + } + + // Right region + if a.x + a.width > inter.x + inter.width { + result.push(Rectangle { + x: inter.x + inter.width, + y: inter.y, + width: a.x + a.width - (inter.x + inter.width), + height: inter.height, + }); + } + + result + } +} + +/// Calculates the gaps between adjacent rectangles along an axis. +/// +/// The rectangles are first sorted by their starting position on the +/// given axis. The returned vector contains the spacing between the end +/// of each rectangle and the start of the next one. +pub fn get_gaps(rectangles: &[Rectangle], axis: super::vector2::Axis) -> Vec { + if rectangles.len() < 2 { + return Vec::new(); + } + + let mut sorted: Vec<&Rectangle> = rectangles.iter().collect(); + sorted.sort_by(|a, b| { + if axis == super::vector2::Axis::X { + a.x.partial_cmp(&b.x).unwrap() + } else { + a.y.partial_cmp(&b.y).unwrap() + } + }); + + let mut gaps = Vec::new(); + for i in 0..sorted.len() - 1 { + let end = if axis == super::vector2::Axis::X { + sorted[i].x + sorted[i].width + } else { + sorted[i].y + sorted[i].height + }; + let next_start = if axis == super::vector2::Axis::X { + sorted[i + 1].x + } else { + sorted[i + 1].y + }; + gaps.push(next_start - end); + } + gaps +} + +/// Calculates the uniform gap between rectangles if present. +/// Returns `(Some(gap), gaps)` if all gaps are equal within `tolerance`. +pub fn get_uniform_gap( + rectangles: &[Rectangle], + axis: super::vector2::Axis, + tolerance: f32, +) -> (Option, Vec) { + let gaps = get_gaps(rectangles, axis); + if gaps.is_empty() { + return (None, gaps); + } + + if crate::utils::is_uniform(&gaps, tolerance) { + let mut best_val = gaps[0]; + let mut best_count = 0; + for &g in &gaps { + let count = gaps.iter().filter(|&&x| x == g).count(); + if count > best_count { + best_count = count; + best_val = g; + } + } + let most = best_val; + (Some(most), gaps) + } else { + (None, gaps) + } +} + +/// Repositions rectangles so they are evenly distributed along the axis while +/// preserving the original ordering. +pub fn distribute_evenly(rectangles: &[Rectangle], axis: super::vector2::Axis) -> Vec { + if rectangles.len() < 2 { + return rectangles.to_vec(); + } + + let bbox = union(rectangles); + let start = if axis == super::vector2::Axis::X { + bbox.x + } else { + bbox.y + }; + let total_size = if axis == super::vector2::Axis::X { + bbox.width + } else { + bbox.height + }; + let total_rect_size: f32 = rectangles + .iter() + .map(|r| { + if axis == super::vector2::Axis::X { + r.width + } else { + r.height + } + }) + .sum(); + + let gap_size = (total_size - total_rect_size) / (rectangles.len() as f32 - 1.0); + + let mut sorted_indices: Vec = (0..rectangles.len()).collect(); + sorted_indices.sort_by(|&a, &b| { + if axis == super::vector2::Axis::X { + rectangles[a].x.partial_cmp(&rectangles[b].x).unwrap() + } else { + rectangles[a].y.partial_cmp(&rectangles[b].y).unwrap() + } + }); + + let mut current = start; + let mut distributed = vec![ + Rectangle { + x: 0.0, + y: 0.0, + width: 0.0, + height: 0.0 + }; + rectangles.len() + ]; + for idx in sorted_indices { + let r = rectangles[idx]; + let mut new_r = r; + if axis == super::vector2::Axis::X { + new_r.x = current; + current += r.width + gap_size; + } else { + new_r.y = current; + current += r.height + gap_size; + } + distributed[idx] = new_r; + } + + distributed +} + +/// Padding or margin values for each side of a rectangle. +#[derive(Debug, Clone, Copy)] +pub struct Sides { + pub top: f32, + pub right: f32, + pub bottom: f32, + pub left: f32, +} + +impl From for Sides { + fn from(all: f32) -> Self { + Self { + top: all, + right: all, + bottom: all, + left: all, + } + } +} + +impl From<[f32; 4]> for Sides { + fn from(v: [f32; 4]) -> Self { + Self { + top: v[0], + right: v[1], + bottom: v[2], + left: v[3], + } + } +} + +/// Quantizes the rectangle position and size by the given step. +pub fn quantize(rect: Rectangle, step: impl super::vector2::IntoVector2) -> Rectangle { + let s = step.into_vector2(); + Rectangle { + x: crate::quantize(rect.x, s[0]), + y: crate::quantize(rect.y, s[1]), + width: crate::quantize(rect.width, s[0]), + height: crate::quantize(rect.height, s[1]), + } +} + +/// Normalizes the rectangle so width and height are positive. +pub fn positive(rect: Rectangle) -> Rectangle { + Rectangle { + x: rect.x.min(rect.x + rect.width), + y: rect.y.min(rect.y + rect.height), + width: rect.width.abs(), + height: rect.height.abs(), + } +} + +/// Returns the aspect ratio `width / height`. +pub fn aspect_ratio(rect: Rectangle) -> f32 { + rect.width / rect.height +} + +/// Returns `[scale_x, scale_y]` required to scale `a` to match `b`. +pub fn get_scale_factors(a: Rectangle, b: Rectangle) -> Vector2 { + [b.width / a.width, b.height / a.height] +} + +use super::transform::AffineTransform; + +/// Returns the transform mapping rectangle `a` onto rectangle `b`. +pub fn get_relative_transform(a: Rectangle, b: Rectangle) -> AffineTransform { + let sx = if a.width == 0.0 { + 1.0 + } else { + b.width / a.width + }; + let sy = if a.height == 0.0 { + 1.0 + } else { + b.height / a.height + }; + + let t1 = AffineTransform::new(-a.x, -a.y, 0.0); + let t2 = AffineTransform { + matrix: [[sx, 0.0, 0.0], [0.0, sy, 0.0]], + }; + let t3 = AffineTransform::new(b.x, b.y, 0.0); + + t3.compose(&t2.compose(&t1)) +} + +/// Applies an affine transform to the rectangle and returns the bounding box. +pub fn transform(rect: Rectangle, t: &AffineTransform) -> Rectangle { + let corners = [ + [rect.x, rect.y], + [rect.x + rect.width, rect.y], + [rect.x, rect.y + rect.height], + [rect.x + rect.width, rect.y + rect.height], + ]; + let transformed: Vec = corners + .iter() + .map(|&p| super::vector2::transform(p, t)) + .collect(); + from_points(&transformed) +} + +/// Rotates the rectangle around its center and returns the bounding box. +pub fn rotate(rect: Rectangle, degrees: f32) -> Rectangle { + let center = rect.center(); + let rad = degrees.to_radians(); + let (sin, cos) = rad.sin_cos(); + let rotate_point = |p: Vector2| -> Vector2 { + let dx = p[0] - center[0]; + let dy = p[1] - center[1]; + [ + center[0] + dx * cos - dy * sin, + center[1] + dx * sin + dy * cos, + ] + }; + let pts = [ + rotate_point([rect.x, rect.y]), + rotate_point([rect.x + rect.width, rect.y]), + rotate_point([rect.x, rect.y + rect.height]), + rotate_point([rect.x + rect.width, rect.y + rect.height]), + ]; + from_points(&pts) +} + +/// Returns the requested cardinal point of the rectangle. +pub fn get_cardinal_point(rect: Rectangle, dir: CardinalDirection) -> Vector2 { + match dir { + CardinalDirection::N => [rect.x + rect.width / 2.0, rect.y], + CardinalDirection::E => [rect.x + rect.width, rect.y + rect.height / 2.0], + CardinalDirection::S => [rect.x + rect.width / 2.0, rect.y + rect.height], + CardinalDirection::W => [rect.x, rect.y + rect.height / 2.0], + CardinalDirection::NE => [rect.x + rect.width, rect.y], + CardinalDirection::SE => [rect.x + rect.width, rect.y + rect.height], + CardinalDirection::SW => [rect.x, rect.y + rect.height], + CardinalDirection::NW => [rect.x, rect.y], + } +} + +/// Returns the center of the rectangle. +pub fn get_center(rect: Rectangle) -> Vector2 { + rect.center() +} + +/// Returns the overlapping projection range of rectangles along the counter axis. +pub fn axis_projection_intersection( + rects: &[Rectangle], + axis: super::vector2::Axis, +) -> Option { + assert!(rects.len() >= 2, "At least two rectangles are required"); + let projections: Vec = rects + .iter() + .map(|r| { + if axis == super::vector2::Axis::X { + [r.y, r.y + r.height] + } else { + [r.x, r.x + r.width] + } + }) + .collect(); + + projections + .iter() + .skip(1) + .fold(Some(projections[0]), |acc, p| { + acc.and_then(|cur| super::vector2::intersection(cur, *p)) + }) +} + +/// Returns true if two rectangles are exactly equal. +pub fn is_identical(a: Rectangle, b: Rectangle) -> bool { + a.x == b.x && a.y == b.y && a.width == b.width && a.height == b.height +} + +/// Returns true if all rectangles in the slice are identical. +pub fn is_uniform(rects: &[Rectangle]) -> bool { + rects.windows(2).all(|w| is_identical(w[0], w[1])) +} + +/// Expands the rectangle by the given padding while keeping its center. +pub fn pad(rect: Rectangle, padding: impl Into) -> Rectangle { + let p = padding.into(); + let cx = rect.x + rect.width / 2.0; + let cy = rect.y + rect.height / 2.0; + let w = rect.width + p.left + p.right; + let h = rect.height + p.top + p.bottom; + Rectangle { + x: cx - w / 2.0, + y: cy - h / 2.0, + width: w, + height: h, + } +} + +/// Insets the rectangle by the given margin while keeping its center. +pub fn inset(rect: Rectangle, margin: impl Into) -> Rectangle { + let m = margin.into(); + let cx = rect.x + rect.width / 2.0; + let cy = rect.y + rect.height / 2.0; + let mut w = rect.width - (m.left + m.right); + let mut h = rect.height - (m.top + m.bottom); + if w < 0.0 { + w = 0.0; + } + if h < 0.0 { + h = 0.0; + } + Rectangle { + x: cx - w / 2.0, + y: cy - h / 2.0, + width: w, + height: h, + } +} + +/// Alignment kind for rectangle positioning. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AlignKind { + None, + Min, + Max, + Center, +} + +/// Horizontal/vertical alignment options. +#[derive(Clone, Copy, Debug)] +pub struct Alignment { + pub horizontal: AlignKind, + pub vertical: AlignKind, +} + +impl Default for Alignment { + fn default() -> Self { + Self { + horizontal: AlignKind::None, + vertical: AlignKind::None, + } + } +} + +/// Aligns rectangles within their bounding box. +pub fn align(rects: &[Rectangle], options: Alignment) -> Vec { + if rects.len() < 2 { + return rects.to_vec(); + } + let bbox = union(rects); + rects + .iter() + .map(|r| { + let mut n = *r; + match options.horizontal { + AlignKind::Min => n.x = bbox.x, + AlignKind::Max => n.x = bbox.x + bbox.width - r.width, + AlignKind::Center => n.x = bbox.x + (bbox.width - r.width) / 2.0, + AlignKind::None => {} + } + match options.vertical { + AlignKind::Min => n.y = bbox.y, + AlignKind::Max => n.y = bbox.y + bbox.height - r.height, + AlignKind::Center => n.y = bbox.y + (bbox.height - r.height) / 2.0, + AlignKind::None => {} + } + n + }) + .collect() +} + +/// Aligns rectangle `a` relative to rectangle `b`. +pub fn align_a(a: Rectangle, b: Rectangle, options: Alignment) -> Rectangle { + let mut r = a; + match options.horizontal { + AlignKind::Min => r.x = b.x, + AlignKind::Max => r.x = b.x + b.width - a.width, + AlignKind::Center => r.x = b.x + (b.width - a.width) / 2.0, + AlignKind::None => {} + } + match options.vertical { + AlignKind::Min => r.y = b.y, + AlignKind::Max => r.y = b.y + b.height - a.height, + AlignKind::Center => r.y = b.y + (b.height - a.height) / 2.0, + AlignKind::None => {} + } + r +} diff --git a/crates/grida-math2/src/region.rs b/crates/grida-math2/src/region.rs new file mode 100644 index 0000000000..b733bd9705 --- /dev/null +++ b/crates/grida-math2/src/region.rs @@ -0,0 +1,92 @@ +//! Region utilities built on rectangles. +//! +//! This module provides simple boolean operations for working with rectangular +//! regions. Regions are represented as collections of [`Rectangle`], allowing +//! composition of multiple rectangles. +//! +//! These helpers are useful for computing complex spatial queries such as +//! subtracting holes from a base region or checking spatial relationships. + +use crate::rect::{self, Rectangle}; + +/// A collection of non-overlapping rectangles treated as a single region. +#[derive(Debug, Clone, PartialEq)] +pub struct Region { + /// Rectangles composing the region. + pub rectangles: Vec, +} + +impl Region { + /// Creates a region from potentially overlapping rectangles. + /// + /// The resulting region will contain non-overlapping rectangles. + pub fn from_rectangles(rectangles: Vec) -> Self { + let mut region = Region { + rectangles: Vec::new(), + }; + for rect in rectangles { + region.add_rectangle(rect); + } + region + } + + /// Adds a rectangle to the region while maintaining non-overlap. + fn add_rectangle(&mut self, rect: Rectangle) { + // Remove parts already covered by the existing region. + let mut parts = vec![rect]; + for exist in &self.rectangles { + let mut next = Vec::new(); + for p in parts { + next.extend(rect::boolean::subtract(p, *exist)); + } + parts = next; + if parts.is_empty() { + break; + } + } + + // Any remaining parts are guaranteed to not overlap with existing + // rectangles. Append them to the region. + self.rectangles.extend(parts); + } +} + +/// Subtracts region `b` from region `a`, returning the remaining region. +pub fn subtract(a: Region, b: Region) -> Region { + let mut current = a.rectangles; + for hole in b.rectangles { + let mut next = Vec::new(); + for rect in current.into_iter() { + next.extend(rect::boolean::subtract(rect, hole)); + } + current = next; + if current.is_empty() { + break; + } + } + Region::from_rectangles(current) +} + +/// Computes the difference of `base` with one or more hole rectangles. +/// +/// Each hole is subtracted sequentially from the remaining regions. Holes that +/// lie completely outside the base rectangle are ignored before performing the +/// subtraction, avoiding unnecessary work and keeping the function side-effect +/// free. +pub fn difference(base: Rectangle, holes: &[Rectangle]) -> Vec { + let filtered: Vec = holes + .iter() + .copied() + .filter(|h| rect::intersects(&base, h)) + .collect(); + + if filtered.is_empty() { + return vec![base]; + } + + subtract( + Region::from_rectangles(vec![base]), + Region::from_rectangles(filtered), + ) + .rectangles +} diff --git a/crates/grida-math2/src/snap.rs b/crates/grida-math2/src/snap.rs new file mode 100644 index 0000000000..62b463c284 --- /dev/null +++ b/crates/grida-math2/src/snap.rs @@ -0,0 +1,487 @@ +pub mod spacing { + use crate::range::{self, Range}; + use crate::utils::mean; + + #[derive(Debug, Clone, Copy, PartialEq)] + pub struct ProjectionPoint { + pub p: f32, + pub o: f32, + pub fwd: i32, + } + + #[derive(Debug, Clone, PartialEq)] + pub struct DistributionGeometry1D { + pub ranges: Vec, + pub loops: Vec<[usize; 2]>, + pub gaps: Vec, + pub a: Vec>, + pub b: Vec>, + } + + pub fn plot_distribution_geometry( + ranges: &[Range], + agent_length: Option, + ) -> DistributionGeometry1D { + let grouped = range::group_ranges_by_uniform_gap(ranges, Some(2), 0.0); + + let mut loops = Vec::new(); + let mut gaps = Vec::new(); + let mut a_points = Vec::new(); + let mut b_points = Vec::new(); + + for (i, group) in grouped.iter().enumerate() { + if group.loop_indices.len() != 2 { + continue; + } + let loop_idx = [group.loop_indices[0], group.loop_indices[1]]; + let gap = group.gap; + let min = group.min; + let max = group.max; + + let mut a: Vec = Vec::new(); + let mut b: Vec = Vec::new(); + + if gap > 0.0 { + a.push(ProjectionPoint { + p: max + gap, + o: max, + fwd: i as i32, + }); + b.push(ProjectionPoint { + p: min - gap, + o: min, + fwd: i as i32, + }); + + if let Some(agent_len) = agent_length { + if loop_idx.len() == 2 && agent_len < gap { + let center_range = [ranges[loop_idx[0]][1], ranges[loop_idx[1]][0]]; + let center = mean(¢er_range); + let egap = (gap - agent_len) / 2.0; + let cpa = center - agent_len / 2.0; + let cpb = center + agent_len / 2.0; + a.push(ProjectionPoint { + p: cpa, + o: cpa - egap, + fwd: -1, + }); + b.push(ProjectionPoint { + p: cpb, + o: cpb + egap, + fwd: -1, + }); + } + } + } + + for (j, test) in grouped.iter().enumerate() { + if i == j || test.gap <= 0.0 { + continue; + } + + if test.max < group.max { + a.push(ProjectionPoint { + p: group.max + test.gap, + o: group.max, + fwd: j as i32, + }); + } + + if test.min > group.min { + b.push(ProjectionPoint { + p: group.min - test.gap, + o: group.min, + fwd: j as i32, + }); + } + } + + loops.push(loop_idx); + gaps.push(gap); + a_points.push(a); + b_points.push(b); + } + + DistributionGeometry1D { + ranges: ranges.to_vec(), + loops, + gaps, + a: a_points, + b: b_points, + } + } +} +pub mod viewport { + use crate::{rect::Rectangle, transform::AffineTransform}; + + /// Margin values for top, right, bottom and left. + #[derive(Debug, Clone, Copy)] + pub struct Margins { + pub top: f32, + pub right: f32, + pub bottom: f32, + pub left: f32, + } + + impl From for Margins { + fn from(all: f32) -> Self { + Self { + top: all, + right: all, + bottom: all, + left: all, + } + } + } + + impl From<[f32; 4]> for Margins { + fn from(arr: [f32; 4]) -> Self { + Self { + top: arr[0], + right: arr[1], + bottom: arr[2], + left: arr[3], + } + } + } + + /// Returns an affine transform that fits `target` inside `viewport` with optional margin. + /// + /// The smaller scale between width and height is used so the entire target + /// remains visible within the viewport. + /// + /// # Parameters + /// - `viewport`: The viewport rectangle. + /// - `target`: The bounding box of the contents. + /// - `margin`: Either a uniform margin or per-side margins `[top, right, bottom, left]`. + /// + /// # Example + /// ``` + /// # use math2::{Rectangle, viewport_transform_to_fit}; + /// let viewport = Rectangle { x: 0.0, y: 0.0, width: 800.0, height: 600.0 }; + /// let target = Rectangle { x: 100.0, y: 50.0, width: 400.0, height: 400.0 }; + /// let t = viewport_transform_to_fit(viewport, target, [50.0, 20.0, 50.0, 20.0]); + /// assert!((t.matrix[0][0] - 1.25).abs() < 0.01); + /// ``` + pub fn transform_to_fit( + viewport: Rectangle, + target: Rectangle, + margin: impl Into, + ) -> AffineTransform { + let m: Margins = margin.into(); + + let v_w = viewport.width - m.left - m.right; + let v_h = viewport.height - m.top - m.bottom; + + if v_w <= 0.0 || v_h <= 0.0 || target.width == 0.0 || target.height == 0.0 { + return AffineTransform { + matrix: [[1.0, 0.0, viewport.x], [0.0, 1.0, viewport.y]], + }; + } + + let scale = f32::min(v_w / target.width, v_h / target.height); + + let vx = viewport.x + m.left + v_w / 2.0; + let vy = viewport.y + m.top + v_h / 2.0; + + let tx = target.x + target.width / 2.0; + let ty = target.y + target.height / 2.0; + + let translate_x = vx - tx * scale; + let translate_y = vy - ty * scale; + + AffineTransform { + matrix: [[scale, 0.0, translate_x], [0.0, scale, translate_y]], + } + } +} + +pub mod axis { + use crate::align; + use crate::vector2::Vector2; + use std::collections::HashSet; + + /// A 2D point that may ignore one axis when snapping. + pub type AxisAlignedPoint = (Option, Option); + + /// Result of 1D snapping with indices of matched agents and anchors. + #[derive(Debug, Clone, PartialEq)] + pub struct Snap1DResult { + pub distance: f32, + pub hit_agent_indices: Vec, + pub hit_anchor_indices: Vec, + } + + /// Snap scalar agents to anchors within `threshold` allowing small tolerance. + pub fn snap1d(agents: &[f32], anchors: &[f32], threshold: f32, tolerance: f32) -> Snap1DResult { + if anchors.is_empty() { + return Snap1DResult { + distance: f32::INFINITY, + hit_agent_indices: vec![], + hit_anchor_indices: vec![], + }; + } + assert!(threshold >= 0.0 && tolerance >= 0.0); + let mut min_delta = f32::INFINITY; + let mut signed_delta = 0.0; + let mut hit_agents = Vec::new(); + let mut hit_anchors: HashSet = HashSet::new(); + for (i, &a) in agents.iter().enumerate() { + let (_snap, delta, idxs) = align::scalar(a, anchors, threshold); + let signed = _snap - a; + if delta.abs() <= threshold { + if min_delta.is_infinite() || (signed - signed_delta).abs() <= tolerance { + hit_agents.push(i); + for idx in idxs { + hit_anchors.insert(idx); + } + if delta.abs() < min_delta.abs() { + min_delta = delta; + signed_delta = signed; + } + } + } + } + if min_delta.is_infinite() { + Snap1DResult { + distance: f32::INFINITY, + hit_agent_indices: vec![], + hit_anchor_indices: vec![], + } + } else { + Snap1DResult { + distance: signed_delta, + hit_agent_indices: hit_agents, + hit_anchor_indices: hit_anchors.into_iter().collect(), + } + } + } + + /// Configuration for per-axis snapping thresholds. + #[derive(Debug, Clone, Copy)] + pub struct Snap2DAxisConfig { + pub x: Option, + pub y: Option, + } + + /// Result from snapping on each axis independently. + #[derive(Debug, Clone, PartialEq)] + pub struct Snap2DAxisAlignedResult { + pub x: Option, + pub y: Option, + } + + /// Snaps 2D points to anchors independently on each axis. + pub fn snap2d_axis_aligned( + agents: &[Vector2], + anchors: &[AxisAlignedPoint], + config: Snap2DAxisConfig, + tolerance: f32, + ) -> Snap2DAxisAlignedResult { + if anchors.is_empty() { + return Snap2DAxisAlignedResult { x: None, y: None }; + } + assert!(!agents.is_empty(), "agents required"); + + let x_agents: Vec = agents.iter().map(|v| v[0]).collect(); + let y_agents: Vec = agents.iter().map(|v| v[1]).collect(); + let x_anchors: Vec = anchors.iter().filter_map(|(x, _)| *x).collect(); + let y_anchors: Vec = anchors.iter().filter_map(|(_, y)| *y).collect(); + + let x = config + .x + .and_then(|t| Some(snap1d(&x_agents, &x_anchors, t, tolerance))); + let y = config + .y + .and_then(|t| Some(snap1d(&y_agents, &y_anchors, t, tolerance))); + Snap2DAxisAlignedResult { x, y } + } + + /// Movement vector that can ignore an axis using `None`. + pub type Movement = (Option, Option); + + /// Normalizes movement treating `None` as zero. + pub fn normalize(m: Movement) -> Vector2 { + [m.0.unwrap_or(0.0), m.1.unwrap_or(0.0)] + } + + /// Locks movement to the dominant axis returning `None` for the other. + pub fn axis_locked_by_dominance(m: Movement) -> Movement { + let abs_x = m.0.unwrap_or(0.0).abs(); + let abs_y = m.1.unwrap_or(0.0).abs(); + if abs_x > abs_y { + (m.0, None) + } else { + (None, m.1) + } + } +} + +/// Higher level snapping helpers operating on rectangles and guides. +pub mod canvas { + use super::{axis, spacing}; + use crate::{ + range::{self, Range}, + rect::{self, Rectangle}, + vector2::{Axis, Vector2}, + }; + + /// Guide line used for snapping on a single axis. + #[derive(Debug, Clone, Copy, PartialEq)] + pub struct Guide { + pub axis: Axis, + pub offset: f32, + } + + /// Result of snapping a rectangle against objects and guides. + #[derive(Debug, Clone, PartialEq)] + pub struct SnapToCanvasResult { + pub translated: Rectangle, + pub delta: Vector2, + } + + fn best_distance(values: &[f32]) -> f32 { + values.iter().fold( + f32::INFINITY, + |acc, &v| if v.abs() < acc.abs() { v } else { acc }, + ) + } + + fn snap_to_guides( + agent: Rectangle, + guides: &[Guide], + config: axis::Snap2DAxisConfig, + tolerance: f32, + ) -> axis::Snap2DAxisAlignedResult { + let x_points = range::to_3points_chunk(range::from_rectangle(&agent, Axis::X)); + let y_points = range::to_3points_chunk(range::from_rectangle(&agent, Axis::Y)); + + let mut x_anchors = Vec::new(); + let mut y_anchors = Vec::new(); + for g in guides { + match g.axis { + Axis::X => x_anchors.push(g.offset), + Axis::Y => y_anchors.push(g.offset), + } + } + + let x = config + .x + .map(|t| axis::snap1d(&x_points, &x_anchors, t, tolerance)); + let y = config + .y + .map(|t| axis::snap1d(&y_points, &y_anchors, t, tolerance)); + + axis::Snap2DAxisAlignedResult { x, y } + } + + fn snap_to_objects_geometry( + agent: Rectangle, + anchors: &[Rectangle], + config: axis::Snap2DAxisConfig, + tolerance: f32, + ) -> axis::Snap2DAxisAlignedResult { + let agent_points = rect::to_9points_chunk(&agent); + let anchor_points: Vec = anchors + .iter() + .flat_map(|r| rect::to_9points_chunk(r)) + .map(|p| (Some(p[0]), Some(p[1]))) + .collect(); + + axis::snap2d_axis_aligned(&agent_points, &anchor_points, config, tolerance) + } + + fn snap_to_objects_space( + agent: Rectangle, + anchors: &[Rectangle], + config: axis::Snap2DAxisConfig, + tolerance: f32, + ) -> axis::Snap2DAxisAlignedResult { + let x_range: Range = [agent.x, agent.x + agent.width]; + let y_range: Range = [agent.y, agent.y + agent.height]; + + let mut x_anchor_ranges = Vec::new(); + let mut y_anchor_ranges = Vec::new(); + for r in anchors { + x_anchor_ranges.push([r.x, r.x + r.width]); + y_anchor_ranges.push([r.y, r.y + r.height]); + } + + let x_snap = config.x.map(|t| { + let geom = spacing::plot_distribution_geometry(&x_anchor_ranges, Some(agent.width)); + let anchors: Vec = geom + .a + .into_iter() + .flat_map(|v| v.into_iter().map(|p| p.p)) + .collect(); + axis::snap1d(&[x_range[0]], &anchors, t, tolerance) + }); + + let y_snap = config.y.map(|t| { + let geom = spacing::plot_distribution_geometry(&y_anchor_ranges, Some(agent.height)); + let anchors: Vec = geom + .a + .into_iter() + .flat_map(|v| v.into_iter().map(|p| p.p)) + .collect(); + axis::snap1d(&[y_range[0]], &anchors, t, tolerance) + }); + + axis::Snap2DAxisAlignedResult { + x: x_snap, + y: y_snap, + } + } + + /// Snaps a rectangle to other rectangles or guide lines. + /// + /// The snapping considers geometry points, spacing between objects and + /// explicit guides, returning the translated rectangle and delta. + pub fn snap_to_canvas_geometry( + agent: Rectangle, + anchors: &[Rectangle], + guides: &[Guide], + config: axis::Snap2DAxisConfig, + tolerance: f32, + ) -> SnapToCanvasResult { + let g = snap_to_guides(agent, guides, config, tolerance); + let geo = snap_to_objects_geometry(agent, anchors, config, tolerance); + + let best_x = best_distance(&[ + g.x.as_ref().map(|r| r.distance).unwrap_or(f32::INFINITY), + geo.x.as_ref().map(|r| r.distance).unwrap_or(f32::INFINITY), + ]); + let best_y = best_distance(&[ + g.y.as_ref().map(|r| r.distance).unwrap_or(f32::INFINITY), + geo.y.as_ref().map(|r| r.distance).unwrap_or(f32::INFINITY), + ]); + + let spc = snap_to_objects_space( + agent.translate([best_x, best_y]), + anchors, + config, + tolerance, + ); + + let final_x = best_distance(&[ + g.x.as_ref().map(|r| r.distance).unwrap_or(f32::INFINITY), + geo.x.as_ref().map(|r| r.distance).unwrap_or(f32::INFINITY), + spc.x.as_ref().map(|r| r.distance).unwrap_or(f32::INFINITY), + ]); + let final_y = best_distance(&[ + g.y.as_ref().map(|r| r.distance).unwrap_or(f32::INFINITY), + geo.y.as_ref().map(|r| r.distance).unwrap_or(f32::INFINITY), + spc.y.as_ref().map(|r| r.distance).unwrap_or(f32::INFINITY), + ]); + + let final_x = if final_x.is_infinite() { 0.0 } else { final_x }; + let final_y = if final_y.is_infinite() { 0.0 } else { final_y }; + + let translated = agent.translate([final_x, final_y]); + + SnapToCanvasResult { + translated, + delta: [final_x, final_y], + } + } +} diff --git a/crates/grida-math2/src/transform.rs b/crates/grida-math2/src/transform.rs new file mode 100644 index 0000000000..ddeb2e962a --- /dev/null +++ b/crates/grida-math2/src/transform.rs @@ -0,0 +1,138 @@ +/// Represents a 2D affine transformation matrix. +/// +/// The matrix is a 2x3 transformation: +/// [ [a, c, tx], +/// [b, d, ty] ] +/// +/// It supports translation and rotation, and can be composed or inverted. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct AffineTransform { + /// The 2x3 transformation matrix: [ [a, c, tx], [b, d, ty] ] + pub matrix: [[f32; 3]; 2], +} + +impl AffineTransform { + /// Returns the identity transform. + /// + /// This is equivalent to no transformation. + pub fn identity() -> Self { + Self { + matrix: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]], + } + } + + /// Creates a rotation transform in degrees, counter-clockwise. + pub fn from_rotatation(degrees: f32) -> Self { + let rad = degrees.to_radians(); + let (sin, cos) = rad.sin_cos(); + + Self { + matrix: [[cos, -sin, 0.0], [sin, cos, 0.0]], + } + } + + /// Creates a combined transform of translation followed by rotation. + pub fn new(tx: f32, ty: f32, rotation: f32) -> Self { + let mut t = Self::identity(); + t.set_translation(tx, ty); + t.set_rotation(rotation); + t + } + + pub fn x(&self) -> f32 { + self.matrix[0][2] + } + pub fn y(&self) -> f32 { + self.matrix[1][2] + } + + pub fn get_scale_x(&self) -> f32 { + (self.matrix[0][0].powi(2) + self.matrix[1][0].powi(2)).sqrt() + } + + pub fn get_scale_y(&self) -> f32 { + (self.matrix[0][1].powi(2) + self.matrix[1][1].powi(2)).sqrt() + } + + /// Returns the scale factors of the transform. + pub fn get_scale(&self) -> (f32, f32) { + (self.get_scale_x(), self.get_scale_y()) + } + + /// Composes this transform with another. + /// + /// This is equivalent to applying `other` after `self`. + pub fn compose(&self, other: &Self) -> Self { + let a = self.matrix; + let b = other.matrix; + + Self { + matrix: [ + [ + a[0][0] * b[0][0] + a[0][1] * b[1][0], + a[0][0] * b[0][1] + a[0][1] * b[1][1], + a[0][0] * b[0][2] + a[0][1] * b[1][2] + a[0][2], + ], + [ + a[1][0] * b[0][0] + a[1][1] * b[1][0], + a[1][0] * b[0][1] + a[1][1] * b[1][1], + a[1][0] * b[0][2] + a[1][1] * b[1][2] + a[1][2], + ], + ], + } + } + + /// Returns the inverse of this affine transform, if it exists. + /// + /// Returns `None` if the matrix is singular (i.e. non-invertible). + pub fn inverse(&self) -> Option { + let [[a, c, tx], [b, d, ty]] = self.matrix; + + let det = a * d - b * c; + if det.abs() < std::f32::EPSILON { + return None; + } + + let inv_det = 1.0 / det; + + let a_inv = d * inv_det; + let b_inv = -b * inv_det; + let c_inv = -c * inv_det; + let d_inv = a * inv_det; + + let tx_inv = -(a_inv * tx + c_inv * ty); + let ty_inv = -(b_inv * tx + d_inv * ty); + + Some(Self { + matrix: [[a_inv, c_inv, tx_inv], [b_inv, d_inv, ty_inv]], + }) + } + + /// Applies a translation to a 2D transform matrix. + pub fn translate(&mut self, tx: f32, ty: f32) { + self.matrix[0][2] += tx; + self.matrix[1][2] += ty; + } + + /// Sets the translation components of the transform. + /// This preserves any existing rotation. + pub fn set_translation(&mut self, tx: f32, ty: f32) { + self.matrix[0][2] = tx; + self.matrix[1][2] = ty; + } + + /// Sets the rotation of the transform in radians. + /// This preserves any existing translation. + pub fn set_rotation(&mut self, angle: f32) { + let (sin, cos) = angle.sin_cos(); + self.matrix[0][0] = cos; + self.matrix[0][1] = -sin; + self.matrix[1][0] = sin; + self.matrix[1][1] = cos; + } + + /// Returns the rotation angle in radians. + pub fn rotation(&self) -> f32 { + self.matrix[1][0].atan2(self.matrix[0][0]) + } +} diff --git a/crates/grida-math2/src/ui.rs b/crates/grida-math2/src/ui.rs new file mode 100644 index 0000000000..0ba89aed23 --- /dev/null +++ b/crates/grida-math2/src/ui.rs @@ -0,0 +1,76 @@ +use crate::{transform::AffineTransform, vector2}; + +/// `['x', 100.0]` will draw a y-axis line at x=100. +pub type Rule = (vector2::Axis, f32); + +#[derive(Debug, Clone, PartialEq)] +pub struct Point { + pub label: Option, + pub x: f32, + pub y: f32, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Line { + pub label: Option, + pub x1: f32, + pub y1: f32, + pub x2: f32, + pub y2: f32, +} + +/// Applies an affine transform to a UI point. +pub fn transform_point(point: &Point, transform: &AffineTransform) -> Point { + let [x, y] = vector2::transform([point.x, point.y], transform); + Point { + label: point.label.clone(), + x, + y, + } +} + +/// Applies an affine transform to a UI line. +pub fn transform_line(line: &Line, transform: &AffineTransform) -> Line { + let [x1, y1] = vector2::transform([line.x1, line.y1], transform); + let [x2, y2] = vector2::transform([line.x2, line.y2], transform); + Line { + label: line.label.clone(), + x1, + y1, + x2, + y2, + } +} + +/// Ensures `(x1, y1) <= (x2, y2)` lexicographically. +pub fn normalize_line(line: &Line) -> Line { + let mut x1 = line.x1; + let mut y1 = line.y1; + let mut x2 = line.x2; + let mut y2 = line.y2; + + if x1 > x2 || (x1 == x2 && y1 > y2) { + std::mem::swap(&mut x1, &mut x2); + std::mem::swap(&mut y1, &mut y2); + } + + Line { + label: line.label.clone(), + x1, + y1, + x2, + y2, + } +} + +/// Formats `num` to the given precision only when necessary. +/// If the rounded value has no fractional part, it is returned without decimals. +pub fn format_number(num: f32, precision: usize) -> String { + let factor = 10_f32.powi(precision as i32); + let rounded = (num * factor).round() / factor; + if rounded.fract() == 0.0 { + format!("{:.0}", rounded) + } else { + format!("{:.*}", precision, rounded) + } +} diff --git a/crates/grida-math2/src/utils.rs b/crates/grida-math2/src/utils.rs new file mode 100644 index 0000000000..2be5a2ae17 --- /dev/null +++ b/crates/grida-math2/src/utils.rs @@ -0,0 +1,134 @@ +/// Quantizes `value` to the nearest multiple of `step`. +/// +/// Useful for rounding or grid alignment of continuous values. +/// +/// # Panics +/// Panics if `step` is not positive. +/// +/// # Example +/// ```rust +/// use math2::quantize; +/// assert_eq!(quantize(15.0, 10.0), 20.0); +/// ``` +pub fn quantize(value: f32, step: f32) -> f32 { + assert!(step > 0.0, "step must be positive"); + let factor = 1.0 / step; + (value * factor).round() / factor +} + +/// Clamps `value` between `min` and `max`. +pub fn clamp(value: f32, min: f32, max: f32) -> f32 { + value.max(min).min(max) +} + +/// Finds the nearest value to `value` from `points`. +/// Returns `f32::INFINITY` if `points` is empty. +pub fn nearest(value: f32, points: &[f32]) -> f32 { + points + .iter() + .map(|&p| (p, (p - value).abs())) + .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap()) + .map(|(p, _)| p) + .unwrap_or(f32::INFINITY) +} + +/// Converts an angle to its principal representation within `[-180, 180)`. +pub fn principal_angle(angle: f32) -> f32 { + ((angle + 180.0) % 360.0) - 180.0 +} + +/// Determines whether an angle is closer to the X or Y axis. +pub fn angle_to_axis(angle: f32) -> super::vector2::Axis { + let a = ((angle % 360.0) + 360.0) % 360.0; + let dist_horizontal = (a - 0.0) + .abs() + .min((a - 180.0).abs()) + .min((a - 360.0).abs()); + let dist_vertical = (a - 90.0).abs().min((a - 270.0).abs()); + if dist_horizontal <= dist_vertical { + super::vector2::Axis::X + } else { + super::vector2::Axis::Y + } +} + +/// Checks if all numbers in `arr` are equal within `tolerance`. +pub fn is_uniform(arr: &[f32], tolerance: f32) -> bool { + if arr.len() <= 1 { + return true; + } + let first = arr[0]; + if tolerance == 0.0 { + arr.iter().all(|&v| v == first) + } else { + arr.iter().all(|&v| (v - first).abs() <= tolerance) + } +} + +/// Computes the mean (average) of the provided values. +pub fn mean(values: &[f32]) -> f32 { + assert!(!values.is_empty(), "cannot compute mean of empty slice"); + let sum: f32 = values.iter().sum(); + sum / values.len() as f32 +} + +/// Generates all combinations of size `k` from the slice. +pub fn combinations(arr: &[T], k: usize) -> Vec> { + if k == 0 { + return vec![vec![]]; + } + if arr.is_empty() { + return vec![]; + } + let (first, rest) = arr.split_first().unwrap(); + let mut with_first: Vec> = combinations(rest, k - 1) + .into_iter() + .map(|mut combo| { + combo.insert(0, first.clone()); + combo + }) + .collect(); + let mut without_first = combinations(rest, k); + with_first.append(&mut without_first); + with_first +} + +/// Generates all permutations of size `k` from the slice. +pub fn permutations(arr: &[T], k: usize) -> Vec> { + if k == 0 { + return vec![vec![]]; + } + if arr.is_empty() { + return vec![]; + } + let mut result = Vec::new(); + for (idx, item) in arr.iter().enumerate() { + let mut rest = arr.to_vec(); + rest.remove(idx); + for mut perm in permutations(&rest, k - 1) { + perm.insert(0, item.clone()); + result.push(perm); + } + } + result +} + +/// Generates the power set of `arr` or subsets of a given size. +pub fn powerset(arr: &[T], k: Option) -> Vec> { + match k { + None => { + let mut result = vec![vec![]]; + for size in 1..=arr.len() { + result.extend(combinations(arr, size)); + } + result + } + Some(size) => { + if size > arr.len() { + vec![] + } else { + combinations(arr, size) + } + } + } +} diff --git a/crates/grida-math2/src/vector2.rs b/crates/grida-math2/src/vector2.rs new file mode 100644 index 0000000000..622c488fe9 --- /dev/null +++ b/crates/grida-math2/src/vector2.rs @@ -0,0 +1,191 @@ +use super::transform::AffineTransform; + +/// A 2-dimensional vector represented as `[x, y]`. +pub type Vector2 = [f32; 2]; + +/// The zero vector `[0, 0]`. +pub const ZERO: Vector2 = [0.0, 0.0]; + +/// Axis in 2D space. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Axis { + X, + Y, +} + +impl Axis { + /// Returns the opposite axis. + pub fn counter(self) -> Self { + match self { + Axis::X => Axis::Y, + Axis::Y => Axis::X, + } + } +} + +/// Constructs a vector assigning `a` to the main axis and `b` to the other. +pub fn axis_oriented(a: f32, b: f32, main_axis: Axis) -> Vector2 { + match main_axis { + Axis::X => [a, b], + Axis::Y => [b, a], + } +} + +/// Returns true if the vector is `[0, 0]`. +pub fn is_zero(v: Vector2) -> bool { + v[0] == 0.0 && v[1] == 0.0 +} + +/// Adds multiple vectors component-wise. +pub fn add(vectors: &[Vector2]) -> Vector2 { + let mut result = [0.0f32, 0.0f32]; + for v in vectors { + result[0] += v[0]; + result[1] += v[1]; + } + result +} + +/// Subtracts vectors sequentially. If only one vector is provided it is returned. +pub fn sub(vectors: &[Vector2]) -> Vector2 { + if vectors.is_empty() { + return [0.0, 0.0]; + } + let mut iter = vectors.iter(); + let mut result = *iter.next().unwrap(); + for v in iter { + result[0] -= v[0]; + result[1] -= v[1]; + } + result +} + +/// Trait converting common scalar/tuple types into [`Vector2`]. +pub trait IntoVector2 { + /// Converts the value into a `Vector2`. + fn into_vector2(self) -> Vector2; +} + +impl IntoVector2 for [f32; 2] { + fn into_vector2(self) -> Vector2 { + self + } +} + +impl IntoVector2 for (f32, f32) { + fn into_vector2(self) -> Vector2 { + [self.0, self.1] + } +} + +impl IntoVector2 for f32 { + fn into_vector2(self) -> Vector2 { + [self, self] + } +} + +/// Quantizes a vector using the provided step or per-axis steps. +pub fn quantize(vector: Vector2, step: impl IntoVector2) -> Vector2 { + let step = step.into_vector2(); + [ + crate::quantize(vector[0], step[0]), + crate::quantize(vector[1], step[1]), + ] +} + +/// Multiplies vectors component-wise. +pub fn multiply(vectors: &[Vector2]) -> Vector2 { + if vectors.is_empty() { + return [1.0, 1.0]; + } + let mut result = [1.0f32, 1.0f32]; + for v in vectors { + result[0] *= v[0]; + result[1] *= v[1]; + } + result +} + +/// Inverts a vector by negating both components. +pub fn invert(v: Vector2) -> Vector2 { + [-v[0], -v[1]] +} + +/// Returns the angle in degrees from `a` to `b` measured counter-clockwise. +pub fn angle(a: Vector2, b: Vector2) -> f32 { + let radians = (b[1] - a[1]).atan2(b[0] - a[0]); + let degrees = radians.to_degrees(); + (degrees + 360.0) % 360.0 +} + +/// Rotates a vector by `angle` degrees counter-clockwise. +pub fn rotate(v: Vector2, angle: f32) -> Vector2 { + let rad = angle.to_radians(); + let (sin, cos) = rad.sin_cos(); + [v[0] * cos - v[1] * sin, v[0] * sin + v[1] * cos] +} + +/// Returns true if two segments intersect or overlap. +pub fn intersects(a: Vector2, b: Vector2) -> bool { + a[1] >= b[0] && b[1] >= a[0] +} + +/// Returns the intersection of two segments if they intersect. +pub fn intersection(a: Vector2, b: Vector2) -> Option { + let start = a[0].max(b[0]); + let end = a[1].min(b[1]); + if start > end { + None + } else { + Some([start, end]) + } +} + +/// Component-wise minimum of vectors. +pub fn min(vectors: &[Vector2]) -> Vector2 { + let mut result = [f32::INFINITY, f32::INFINITY]; + for v in vectors { + if v[0] < result[0] { + result[0] = v[0]; + } + if v[1] < result[1] { + result[1] = v[1]; + } + } + result +} + +/// Component-wise maximum of vectors. +pub fn max(vectors: &[Vector2]) -> Vector2 { + let mut result = [f32::NEG_INFINITY, f32::NEG_INFINITY]; + for v in vectors { + if v[0] > result[0] { + result[0] = v[0]; + } + if v[1] > result[1] { + result[1] = v[1]; + } + } + result +} + +/// Clamps vector components between min and max. +pub fn clamp(v: Vector2, min: Vector2, max: Vector2) -> Vector2 { + [v[0].max(min[0]).min(max[0]), v[1].max(min[1]).min(max[1])] +} + +/// Euclidean distance between two vectors. +pub fn distance(a: Vector2, b: Vector2) -> f32 { + ((b[0] - a[0]).powi(2) + (b[1] - a[1]).powi(2)).sqrt() +} + +/// Applies an affine transform to a vector. +pub fn transform(v: Vector2, t: &AffineTransform) -> Vector2 { + let [[a, c, tx], [b, d, ty]] = t.matrix; + [a * v[0] + c * v[1] + tx, b * v[0] + d * v[1] + ty] +} + +/// Returns true if two vectors are identical. +pub fn identical(a: Vector2, b: Vector2) -> bool { + a[0] == b[0] && a[1] == b[1] +} diff --git a/crates/grida-math2/src/vector4.rs b/crates/grida-math2/src/vector4.rs new file mode 100644 index 0000000000..b8c1070879 --- /dev/null +++ b/crates/grida-math2/src/vector4.rs @@ -0,0 +1,7 @@ +/// A 4-dimensional vector. +pub type Vector4 = [f32; 4]; + +/// Returns true if two vectors are identical. +pub fn identical(a: Vector4, b: Vector4) -> bool { + a[0] == b[0] && a[1] == b[1] && a[2] == b[2] && a[3] == b[3] +} diff --git a/crates/grida-math2/tests/align.rs b/crates/grida-math2/tests/align.rs new file mode 100644 index 0000000000..67a525ba87 --- /dev/null +++ b/crates/grida-math2/tests/align.rs @@ -0,0 +1,37 @@ +use math2::{align_scalar, align_vector2}; + +#[test] +fn scalar_snaps_to_nearest_within_threshold() { + let (value, dist, idx) = align_scalar(15.0, &[10.0, 20.0, 25.0], 6.0); + assert_eq!(value, 10.0); + assert_eq!(dist, 5.0); + assert_eq!(idx, vec![0, 1]); +} + +#[test] +fn scalar_returns_original_when_out_of_threshold() { + let (value, dist, idx) = align_scalar(15.0, &[1.0, 2.0, 3.0], 5.0); + assert_eq!(value, 15.0); + assert!(dist.is_infinite()); + assert!(idx.is_empty()); +} + +#[test] +fn vector2_snaps_to_nearest_within_threshold() { + let point = [5.0, 5.0]; + let targets = &[[0.0, 0.0], [10.0, 10.0], [6.0, 7.0]]; + let (value, dist, idx) = align_vector2(point, targets, 5.0); + assert_eq!(value, [6.0, 7.0]); + assert!((dist - 2.236).abs() < 0.01); + assert_eq!(idx, vec![2]); +} + +#[test] +fn vector2_returns_original_when_out_of_threshold() { + let point = [5.0, 5.0]; + let targets = &[[10.0, 10.0], [20.0, 20.0]]; + let (value, dist, idx) = align_vector2(point, targets, 4.0); + assert_eq!(value, point); + assert!(dist.is_infinite()); + assert!(idx.is_empty()); +} diff --git a/crates/grida-math2/tests/bezier.rs b/crates/grida-math2/tests/bezier.rs new file mode 100644 index 0000000000..076550fc21 --- /dev/null +++ b/crates/grida-math2/tests/bezier.rs @@ -0,0 +1,100 @@ +use math2::bezier_a2c; + +/// Converts the output of `bezier_a2c` to an SVG path string. +fn a2c_to_svg_path(x1: f32, y1: f32, data: &[f32]) -> String { + let mut path = format!("M {} {}", x1, y1); + for chunk in data.chunks(6) { + if let [c1x, c1y, c2x, c2y, x, y] = *chunk { + path.push_str(&format!(" C {} {}, {} {}, {} {}", c1x, c1y, c2x, c2y, x, y)); + } + } + path +} + +/// Helper to prepend the start point to the cubic bezier points. +fn get_bezier_points(x1: f32, y1: f32, data: &[f32]) -> Vec { + let mut out = Vec::with_capacity(2 + data.len()); + out.push(x1); + out.push(y1); + out.extend_from_slice(data); + out +} + +#[test] +fn simple_arc_svg_path() { + let res = bezier_a2c(0.0, 0.0, 1.0, 1.0, 0.0, false, false, 100.0, 0.0, None); + let d = a2c_to_svg_path(0.0, 0.0, &res); + assert_eq!( + d, + "M 0 0 C -0.0000033649048 38.490025, 41.66666 62.546284, 75 43.301273 C 90.470055 34.369633, 100 17.863281, 100 0" + ); +} + +#[test] +fn simple_arc_values() { + let x1 = 0.0; + let y1 = 0.0; + let rx = 50.0; + let ry = 50.0; + let angle = 0.0; + let large_arc_flag = false; + let sweep_flag = true; + let x2 = 50.0; + let y2 = 50.0; + + let res = bezier_a2c( + x1, + y1, + rx, + ry, + angle, + large_arc_flag, + sweep_flag, + x2, + y2, + None, + ); + let pts = get_bezier_points(x1, y1, &res); + assert_eq!(pts.len(), 8); + assert!((pts[0] - x1).abs() < 1e-6); + assert!((pts[1] - y1).abs() < 1e-6); + assert!((pts[6] - x2).abs() < 1e-6); + assert!((pts[7] - y2).abs() < 1e-6); + assert!((pts[2] - 27.614237).abs() < 0.001); + assert!((pts[3]).abs() < 1e-5); + assert!((pts[4] - 50.0).abs() < 1e-6); + assert!((pts[5] - 22.385763).abs() < 0.001); +} + +#[test] +fn large_arc_flag() { + let x1 = 0.0; + let y1 = 0.0; + let rx = 50.0; + let ry = 50.0; + let angle = 0.0; + let large_arc_flag = true; + let sweep_flag = true; + let x2 = 50.0; + let y2 = 50.0; + + let res = bezier_a2c( + x1, + y1, + rx, + ry, + angle, + large_arc_flag, + sweep_flag, + x2, + y2, + None, + ); + let pts = get_bezier_points(x1, y1, &res); + assert!(pts.len() > 8); + assert_eq!(pts.len(), 20); + assert!((pts[0] - x1).abs() < 1e-6); + assert!((pts[1] - y1).abs() < 1e-6); + assert!((pts[pts.len() - 2] - x2).abs() < 1e-6); + assert!((pts[pts.len() - 1] - y2).abs() < 1e-6); +} diff --git a/crates/grida-math2/tests/color.rs b/crates/grida-math2/tests/color.rs new file mode 100644 index 0000000000..5133f4b4d5 --- /dev/null +++ b/crates/grida-math2/tests/color.rs @@ -0,0 +1,65 @@ +use math2::{ + RGBA8888, RGBAf, hex_to_rgba8888, rgba_to_unit8_chunk, rgba8888_to_hex, rgbaf_to_rgba8888, +}; + +#[test] +fn hex_short() { + let c = hex_to_rgba8888("#F80"); + assert_eq!( + c, + RGBA8888 { + r: 255, + g: 136, + b: 0, + a: 1.0 + } + ); +} + +#[test] +fn hex_long() { + let c = hex_to_rgba8888("#00ff0080"); + assert_eq!(c.r, 0); + assert_eq!(c.g, 255); + assert_eq!(c.b, 0); + assert!((c.a - 0.5019608).abs() < 0.00001); +} + +#[test] +fn rgba_to_hex_roundtrip() { + let c = RGBA8888 { + r: 10, + g: 20, + b: 30, + a: 0.5, + }; + let hex = rgba8888_to_hex(c); + assert_eq!(hex, "#0a141e80"); + let out = hex_to_rgba8888(&hex); + assert_eq!(out.r, c.r); + assert_eq!(out.g, c.g); + assert_eq!(out.b, c.b); + assert!((out.a - c.a).abs() < 0.01); +} + +#[test] +fn rgbaf_conversion() { + let c = RGBAf { + r: 1.0, + g: 0.5, + b: 0.0, + a: 0.75, + }; + let i = rgbaf_to_rgba8888(c); + assert_eq!( + i, + RGBA8888 { + r: 255, + g: 128, + b: 0, + a: 0.75 + } + ); + let v = rgba_to_unit8_chunk(i); + assert_eq!(v, [255.0, 128.0, 0.0, 191.0]); +} diff --git a/crates/grida-math2/tests/delta.rs b/crates/grida-math2/tests/delta.rs new file mode 100644 index 0000000000..1cbdb131e2 --- /dev/null +++ b/crates/grida-math2/tests/delta.rs @@ -0,0 +1,25 @@ +use math2::vector2::Axis; +use math2::{delta_transform, transform::AffineTransform}; + +#[test] +fn identity() { + let t = AffineTransform::identity(); + assert_eq!(delta_transform(5.0, Axis::X, &t), 5.0); + assert_eq!(delta_transform(7.0, Axis::Y, &t), 7.0); +} + +#[test] +fn translation() { + let t = AffineTransform::new(10.0, 20.0, 0.0); + assert_eq!(delta_transform(5.0, Axis::X, &t), 15.0); + assert_eq!(delta_transform(7.0, Axis::Y, &t), 27.0); +} + +#[test] +fn scaling() { + let t = AffineTransform { + matrix: [[2.0, 0.0, 0.0], [0.0, 3.0, 0.0]], + }; + assert_eq!(delta_transform(4.0, Axis::X, &t), 8.0); + assert_eq!(delta_transform(4.0, Axis::Y, &t), 12.0); +} diff --git a/crates/grida-math2/tests/layout.rs b/crates/grida-math2/tests/layout.rs new file mode 100644 index 0000000000..9cf69b4c9d --- /dev/null +++ b/crates/grida-math2/tests/layout.rs @@ -0,0 +1,58 @@ +use math2::{FlexAxisDirection, Rectangle, layout_flex_guess}; + +fn rect(x: f32, y: f32, w: f32, h: f32) -> Rectangle { + Rectangle { + x, + y, + width: w, + height: h, + } +} + +#[test] +fn single_rectangle_defaults() { + let input = [rect(0.0, 0.0, 100.0, 100.0)]; + let result = layout_flex_guess(&input); + assert_eq!(result.direction, FlexAxisDirection::Horizontal); + assert_eq!(result.spacing, 0.0); +} + +#[test] +fn horizontal_when_width_spread_greater() { + let input = [ + rect(0.0, 0.0, 50.0, 50.0), + rect(60.0, 0.0, 50.0, 50.0), + rect(120.0, 0.0, 50.0, 50.0), + ]; + let result = layout_flex_guess(&input); + assert_eq!(result.direction, FlexAxisDirection::Horizontal); + assert!((result.spacing - 10.0).abs() < 1e-6); +} + +#[test] +fn vertical_when_height_spread_greater() { + let input = [ + rect(0.0, 0.0, 20.0, 50.0), + rect(0.0, 60.0, 20.0, 50.0), + rect(0.0, 120.0, 20.0, 50.0), + ]; + let result = layout_flex_guess(&input); + assert_eq!(result.direction, FlexAxisDirection::Vertical); + assert!((result.spacing - 10.0).abs() < 1e-6); +} + +#[test] +fn overlapping_rectangles_still_returns() { + let input = [ + rect(0.0, 0.0, 80.0, 100.0), + rect(50.0, 30.0, 70.0, 60.0), + rect(140.0, 20.0, 50.0, 40.0), + rect(90.0, 10.0, 40.0, 80.0), + ]; + let result = layout_flex_guess(&input); + assert!(matches!( + result.direction, + FlexAxisDirection::Horizontal | FlexAxisDirection::Vertical + )); + assert!(result.spacing >= 0.0); +} diff --git a/crates/grida-math2/tests/measurement.rs b/crates/grida-math2/tests/measurement.rs new file mode 100644 index 0000000000..7ddb8ec134 --- /dev/null +++ b/crates/grida-math2/tests/measurement.rs @@ -0,0 +1,34 @@ +use math2::{Rectangle, RectangleSide, auxiliary_line_xylr, guide_line_xylr, measure}; + +fn rect(x: f32, y: f32, w: f32, h: f32) -> Rectangle { + Rectangle { + x, + y, + width: w, + height: h, + } +} + +#[test] +fn measure_non_intersecting() { + let a = rect(0.0, 0.0, 50.0, 50.0); + let b = rect(100.0, 0.0, 20.0, 20.0); + let m = measure(a, b).unwrap(); + assert_eq!(m.box_rect, a); + assert_eq!(m.distance, [0.0, 50.0, 0.0, 0.0]); +} + +#[test] +fn guide_line_top() { + let r = rect(10.0, 20.0, 40.0, 30.0); + let line = guide_line_xylr(r, RectangleSide::Top, 10.0, 1.0); + assert_eq!(line, [30.0, 20.0, 30.0, 10.0, 10.0, 180.0]); +} + +#[test] +fn auxiliary_line_left_outside() { + let p = [0.0, 40.0]; + let r = rect(10.0, 20.0, 40.0, 30.0); + let line = auxiliary_line_xylr(p, r, RectangleSide::Top, 1.0); + assert!(line[4] > 0.0); // length positive +} diff --git a/crates/grida-math2/tests/packing.rs b/crates/grida-math2/tests/packing.rs new file mode 100644 index 0000000000..9dfd6d064b --- /dev/null +++ b/crates/grida-math2/tests/packing.rs @@ -0,0 +1,57 @@ +use math2::{Rectangle, packing_fit}; + +fn rect(x: f32, y: f32, w: f32, h: f32) -> Rectangle { + Rectangle { + x, + y, + width: w, + height: h, + } +} + +#[test] +fn returns_top_left_when_no_anchors() { + let view = rect(0.0, 0.0, 100.0, 100.0); + let agent = (10.0, 10.0); + let res = packing_fit(view, agent, &[]); + assert_eq!(res, Some(rect(0.0, 0.0, 10.0, 10.0))); +} + +#[test] +fn next_free_region_when_anchor_blocks_top_left() { + let view = rect(0.0, 0.0, 100.0, 100.0); + let agent = (10.0, 10.0); + let anchors = [rect(0.0, 0.0, 50.0, 50.0)]; + let res = packing_fit(view, agent, &anchors); + assert_eq!(res, Some(rect(50.0, 0.0, 10.0, 10.0))); +} + +#[test] +fn returns_none_when_agent_larger_than_view() { + let view = rect(0.0, 0.0, 30.0, 30.0); + let agent = (40.0, 40.0); + let res = packing_fit(view, agent, &[]); + assert!(res.is_none()); +} + +#[test] +fn top_most_free_region_when_multiple_free_areas() { + let view = rect(0.0, 0.0, 100.0, 100.0); + let agent = (20.0, 20.0); + let anchors = [rect(40.0, 40.0, 20.0, 20.0)]; + let res = packing_fit(view, agent, &anchors); + assert_eq!(res, Some(rect(0.0, 0.0, 20.0, 20.0))); +} + +#[test] +fn complex_anchors_choose_lexicographically_smallest() { + let view = rect(0.0, 0.0, 200.0, 200.0); + let agent = (50.0, 50.0); + let anchors = [ + rect(25.0, 25.0, 100.0, 50.0), + rect(75.0, 75.0, 100.0, 100.0), + rect(-50.0, 150.0, 100.0, 50.0), + ]; + let res = packing_fit(view, agent, &anchors); + assert_eq!(res, Some(rect(125.0, 25.0, 50.0, 50.0))); +} diff --git a/crates/grida-math2/tests/range.rs b/crates/grida-math2/tests/range.rs new file mode 100644 index 0000000000..850fb92e09 --- /dev/null +++ b/crates/grida-math2/tests/range.rs @@ -0,0 +1,17 @@ +use math2::{Range, group_ranges_by_uniform_gap}; + +fn r(a: f32, b: f32) -> Range { + [a, b] +} + +#[test] +fn group_three_uniform_gaps() { + let ranges = vec![r(0.0, 10.0), r(15.0, 25.0), r(30.0, 40.0)]; + let result = group_ranges_by_uniform_gap(&ranges, None, 0.0); + assert!(result.contains(&math2::range::UniformGapGroup { + loop_indices: vec![0, 1, 2], + min: 0.0, + max: 40.0, + gap: 5.0 + })); +} diff --git a/crates/grida-math2/tests/raster.rs b/crates/grida-math2/tests/raster.rs new file mode 100644 index 0000000000..d1a76b2c3c --- /dev/null +++ b/crates/grida-math2/tests/raster.rs @@ -0,0 +1,26 @@ +use math2::{Rectangle, raster_bresenham, raster_rectangle}; + +#[test] +fn bresenham_single_point() { + let res = raster_bresenham([0.0, 0.0], [0.0, 0.0]); + assert_eq!(res, vec![[0.0, 0.0]]); +} + +#[test] +fn bresenham_horizontal() { + let res = raster_bresenham([0.0, 0.0], [3.0, 0.0]); + assert_eq!(res, vec![[0.0, 0.0], [1.0, 0.0], [2.0, 0.0], [3.0, 0.0]]); +} + +#[test] +fn raster_rectangle_points() { + let rect = Rectangle { + x: 0.0, + y: 0.0, + width: 1.0, + height: 1.0, + }; + let pts = raster_rectangle(&rect); + assert!(pts.contains(&[0.0, 0.0])); + assert!(pts.contains(&[1.0, 1.0])); +} diff --git a/crates/grida-math2/tests/raster_extended.rs b/crates/grida-math2/tests/raster_extended.rs new file mode 100644 index 0000000000..7d32afe2a3 --- /dev/null +++ b/crates/grida-math2/tests/raster_extended.rs @@ -0,0 +1,90 @@ +use math2::{ + Bitmap, raster_circle, raster_ellipse, raster_floodfill, raster_gaussian, raster_pad, + raster_pascaltriangle, raster_resize, raster_scale, raster_smoothstep, raster_tile, +}; + +fn bmp(w: usize, h: usize, color: [u8; 4]) -> Bitmap { + let mut data = Vec::new(); + for _ in 0..w * h { + data.extend_from_slice(&color); + } + Bitmap { + width: w, + height: h, + data, + } +} + +#[test] +fn tile_dimensions() { + let src = bmp(1, 1, [1, 2, 3, 4]); + let out = raster_tile(&src, 2, 2); + assert_eq!(out.width, 2); + assert_eq!(out.height, 2); + assert_eq!( + out.data, + vec![1, 2, 3, 4, 1, 2, 3, 4, 1, 2, 3, 4, 1, 2, 3, 4] + ); +} + +#[test] +fn scale_up_size() { + let src = bmp(2, 2, [1, 0, 0, 255]); + let out = raster_scale(&src, [2.0, 2.0]); + assert_eq!(out.width, 4); + assert_eq!(out.height, 4); +} + +#[test] +fn resize_changes_dimensions() { + let src = bmp(2, 2, [0, 0, 0, 0]); + let out = raster_resize(&src, [3.0, 1.0]); + assert_eq!(out.width, 3); + assert_eq!(out.height, 1); +} + +#[test] +fn pad_centers_bitmap() { + let src = bmp(1, 1, [5, 5, 5, 5]); + let out = raster_pad(&src, [3.0, 3.0], [0.0, 0.0, 0.0, 0.0]); + // center pixel should be original color + let idx = (1 * 3 + 1) * 4; + assert_eq!(&out.data[idx..idx + 4], &[5, 5, 5, 5]); +} + +#[test] +fn circle_contains_center() { + let pts = raster_circle([0.0, 0.0], 1.0, None); + assert!(pts.contains(&[0.0, 0.0])); +} + +#[test] +fn ellipse_contains_center() { + let pts = raster_ellipse([0.0, 0.0], [2.0, 1.0]); + assert!(pts.contains(&[0.0, 0.0])); +} + +#[test] +fn floodfill_changes_color() { + let mut bmp = bmp(2, 2, [0, 0, 0, 255]); + raster_floodfill(&mut bmp, [0.0, 0.0], [255.0, 0.0, 0.0, 255.0]); + assert_eq!(bmp.data[..4], [255, 0, 0, 255]); +} + +#[test] +fn gaussian_bounds() { + let v = raster_gaussian(0.5, 0.5); + assert!(v > 0.0 && v <= 1.0); +} + +#[test] +fn smoothstep_basic() { + let v = raster_smoothstep(2, 0.5); + assert!(v > 0.0 && v < 1.0); +} + +#[test] +fn pascaltriangle_known() { + let v = raster_pascaltriangle(5.0, 2); + assert!((v - 10.0).abs() < 1e-6); +} diff --git a/crates/grida-math2/tests/rect.rs b/crates/grida-math2/tests/rect.rs new file mode 100644 index 0000000000..93a59e3298 --- /dev/null +++ b/crates/grida-math2/tests/rect.rs @@ -0,0 +1,129 @@ +use math2::vector2::{self, Axis}; +use math2::{ + AlignKind, Alignment, CardinalDirection, Rectangle, axis_projection_intersection, from_points, + get_cardinal_point, get_relative_transform, intersection, offset, rect_align as align, + rect_inset as inset, rect_pad as pad, rect_rotate, to_9points_chunk, union, +}; + +fn rect(x: f32, y: f32, w: f32, h: f32) -> Rectangle { + Rectangle { + x, + y, + width: w, + height: h, + } +} + +#[test] +fn from_points_basic() { + let pts = [[10.0, 20.0], [30.0, 40.0], [5.0, 25.0]]; + let r = from_points(&pts); + assert_eq!(r, rect(5.0, 20.0, 25.0, 20.0)); +} + +#[test] +fn to_9points_chunk_order() { + let r = rect(10.0, 20.0, 30.0, 40.0); + let pts = to_9points_chunk(&r); + assert_eq!(pts[0], [10.0, 20.0]); + assert_eq!(pts[8], [25.0, 40.0]); +} + +#[test] +fn contains_true() { + let inner = rect(20.0, 20.0, 30.0, 30.0); + let outer = rect(10.0, 10.0, 100.0, 100.0); + assert!(outer.contains(&inner)); +} + +#[test] +fn offset_top_left() { + let r = rect(10.0, 10.0, 100.0, 50.0); + assert_eq!(offset(&r, [5.0, 5.0]), [-5.0, -5.0]); +} + +#[test] +fn intersection_partial() { + let a = rect(10.0, 10.0, 30.0, 30.0); + let b = rect(25.0, 25.0, 20.0, 20.0); + assert_eq!(intersection(&a, &b), Some(rect(25.0, 25.0, 15.0, 15.0))); +} + +#[test] +fn union_multiple() { + let rects = [rect(10.0, 10.0, 30.0, 40.0), rect(50.0, 20.0, 20.0, 30.0)]; + assert_eq!(union(&rects), rect(10.0, 10.0, 60.0, 40.0)); +} + +#[test] +fn pad_uniform() { + let r = rect(50.0, 50.0, 100.0, 80.0); + let padded = pad(r, 10.0); + assert_eq!(padded, rect(40.0, 40.0, 120.0, 100.0)); +} + +#[test] +fn inset_uniform() { + let r = rect(50.0, 50.0, 100.0, 80.0); + let inset_r = inset(r, 10.0); + assert_eq!(inset_r, rect(60.0, 60.0, 80.0, 60.0)); +} + +#[test] +fn align_center() { + let rects = [rect(10.0, 10.0, 30.0, 40.0), rect(50.0, 20.0, 20.0, 30.0)]; + let out = align( + &rects, + Alignment { + horizontal: AlignKind::Center, + vertical: AlignKind::Center, + }, + ); + assert_eq!(out[0].x, 25.0); + assert_eq!(out[1].y, 15.0); +} + +#[test] +fn relative_transform_maps_corners() { + let a = rect(0.0, 0.0, 100.0, 50.0); + let b = rect(200.0, 300.0, 400.0, 200.0); + let t = get_relative_transform(a, b); + assert_eq!(vector2::transform([0.0, 0.0], &t), [200.0, 300.0]); + assert_eq!(vector2::transform([100.0, 50.0], &t), [600.0, 500.0]); +} + +#[test] +fn rotate_45() { + let r = rect(10.0, 10.0, 50.0, 30.0); + let rotated = rect_rotate(r, 45.0); + assert!(rotated.width > r.width); +} + +#[test] +fn cardinal_point_sw() { + let r = rect(0.0, 0.0, 10.0, 10.0); + assert_eq!(get_cardinal_point(r, CardinalDirection::SW), [0.0, 10.0]); +} + +#[test] +fn axis_projection_intersection_overlaps() { + let rects = [rect(10.0, 10.0, 30.0, 30.0), rect(20.0, 15.0, 40.0, 10.0)]; + let inter = axis_projection_intersection(&rects, Axis::X); + assert_eq!(inter, Some([15.0, 25.0])); +} + +#[test] +fn tile_basic() { + let r = rect(0.0, 0.0, 100.0, 40.0); + let out = math2::rect_tile(r, (2, 2)); + assert_eq!(out.len(), 4); + assert_eq!(out[0], rect(0.0, 0.0, 50.0, 20.0)); + assert_eq!(out[3], rect(50.0, 20.0, 50.0, 20.0)); +} + +#[test] +#[should_panic] +fn tile_invalid_divisor() { + let r = rect(0.0, 0.0, 100.0, 40.0); + let _ = math2::rect_tile(r, (3, 2)); +} diff --git a/crates/grida-math2/tests/rect_boolean.rs b/crates/grida-math2/tests/rect_boolean.rs new file mode 100644 index 0000000000..bfe8ccab98 --- /dev/null +++ b/crates/grida-math2/tests/rect_boolean.rs @@ -0,0 +1,60 @@ +use math2::{Rectangle, rect_boolean_subtract}; + +fn rect(x: f32, y: f32, w: f32, h: f32) -> Rectangle { + Rectangle { + x, + y, + width: w, + height: h, + } +} + +#[test] +fn subtract_no_intersection() { + let a = rect(10.0, 10.0, 30.0, 30.0); + let b = rect(50.0, 50.0, 10.0, 10.0); + assert_eq!(rect_boolean_subtract(a, b), vec![a]); +} + +#[test] +fn subtract_full_cover() { + let a = rect(10.0, 10.0, 30.0, 30.0); + let b = rect(5.0, 5.0, 40.0, 40.0); + let result: Vec = vec![]; + assert_eq!(rect_boolean_subtract(a, b), result); +} + +#[test] +fn subtract_full_inner_intersection() { + let a = rect(10.0, 10.0, 30.0, 30.0); + let b = rect(20.0, 20.0, 10.0, 10.0); + let expected = vec![ + rect(10.0, 10.0, 30.0, 10.0), + rect(10.0, 30.0, 30.0, 10.0), + rect(10.0, 20.0, 10.0, 10.0), + rect(30.0, 20.0, 10.0, 10.0), + ]; + assert_eq!(rect_boolean_subtract(a, b), expected); +} + +#[test] +fn subtract_partial_overlap() { + let a = rect(10.0, 10.0, 30.0, 30.0); + let b = rect(25.0, 5.0, 20.0, 20.0); + let expected = vec![rect(10.0, 25.0, 30.0, 15.0), rect(10.0, 10.0, 15.0, 15.0)]; + assert_eq!(rect_boolean_subtract(a, b), expected); +} + +#[test] +fn subtract_zero_area() { + let a = rect(10.0, 10.0, 30.0, 30.0); + let b = rect(20.0, 20.0, 0.0, 0.0); + assert_eq!(rect_boolean_subtract(a, b), vec![a]); +} + +#[test] +fn subtract_touching_edges() { + let a = rect(10.0, 10.0, 30.0, 30.0); + let b = rect(40.0, 10.0, 20.0, 30.0); + assert_eq!(rect_boolean_subtract(a, b), vec![a]); +} diff --git a/crates/grida-math2/tests/region.rs b/crates/grida-math2/tests/region.rs new file mode 100644 index 0000000000..0800337d85 --- /dev/null +++ b/crates/grida-math2/tests/region.rs @@ -0,0 +1,38 @@ +use math2::{Rectangle, Region, region_difference, region_subtract}; + +fn rect(x: f32, y: f32, w: f32, h: f32) -> Rectangle { + Rectangle { + x, + y, + width: w, + height: h, + } +} + +#[test] +fn difference_multiple_holes() { + let base = rect(0.0, 0.0, 30.0, 30.0); + let holes = [rect(10.0, 10.0, 10.0, 10.0), rect(0.0, 0.0, 5.0, 30.0)]; + let result = region_difference(base, &holes); + let expected = vec![ + rect(5.0, 0.0, 25.0, 10.0), + rect(5.0, 20.0, 25.0, 10.0), + rect(5.0, 10.0, 5.0, 10.0), + rect(20.0, 10.0, 10.0, 10.0), + ]; + assert_eq!(result, expected); +} + +#[test] +fn region_subtract_multiple() { + let r1 = rect(0.0, 0.0, 10.0, 10.0); + let r2 = rect(15.0, 0.0, 10.0, 10.0); + let hole = rect(5.0, 0.0, 10.0, 10.0); + + let a = Region::from_rectangles(vec![r1, r2]); + let b = Region::from_rectangles(vec![hole]); + + let result = region_subtract(a, b); + + assert_eq!(result.rectangles, vec![rect(0.0, 0.0, 5.0, 10.0), r2]); +} diff --git a/crates/grida-math2/tests/snap_advanced.rs b/crates/grida-math2/tests/snap_advanced.rs new file mode 100644 index 0000000000..ffb0b35dac --- /dev/null +++ b/crates/grida-math2/tests/snap_advanced.rs @@ -0,0 +1,37 @@ +use math2::{ + AxisAlignedPoint, Snap1DResult, Snap2DAxisConfig, axis_locked_by_dominance, movement_normalize, + snap1d, snap2d_axis_aligned, +}; + +#[test] +fn snap1d_basic() { + let r = snap1d(&[8.0], &[0.0, 10.0], 5.0, 0.0); + assert_eq!(r.distance, 2.0); + assert_eq!(r.hit_agent_indices, vec![0]); + assert_eq!(r.hit_anchor_indices, vec![1]); +} + +#[test] +fn snap2d_axis() { + let agents = [[5.0, 5.0]]; + let anchors: [AxisAlignedPoint; 2] = [(Some(10.0), Some(5.0)), (None, Some(2.0))]; + let res = snap2d_axis_aligned( + &agents, + &anchors, + Snap2DAxisConfig { + x: Some(6.0), + y: Some(4.0), + }, + 0.0, + ); + assert!(res.x.is_some()); + assert!(res.y.is_some()); +} + +#[test] +fn movement_helpers() { + let v = movement_normalize((Some(2.0), None)); + assert_eq!(v, [2.0, 0.0]); + let locked = axis_locked_by_dominance((Some(3.0), Some(1.0))); + assert_eq!(locked, (Some(3.0), None)); +} diff --git a/crates/grida-math2/tests/snap_canvas.rs b/crates/grida-math2/tests/snap_canvas.rs new file mode 100644 index 0000000000..2558bf45b0 --- /dev/null +++ b/crates/grida-math2/tests/snap_canvas.rs @@ -0,0 +1,32 @@ +use math2::{Rectangle, Snap2DAxisConfig, SnapGuide, snap_to_canvas_geometry}; + +fn rect(x: f32, y: f32, w: f32, h: f32) -> Rectangle { + Rectangle { + x, + y, + width: w, + height: h, + } +} + +#[test] +fn snap_to_canvas_by_guide() { + let agent = rect(5.0, 5.0, 10.0, 10.0); + let anchors = [] as [Rectangle; 0]; + let guides = [SnapGuide { + axis: math2::vector2::Axis::X, + offset: 20.0, + }]; + let res = snap_to_canvas_geometry( + agent, + &anchors, + &guides, + Snap2DAxisConfig { + x: Some(6.0), + y: None, + }, + 0.0, + ); + assert_eq!(res.delta, [5.0, 0.0]); + assert_eq!(res.translated.x, 10.0); +} diff --git a/crates/grida-math2/tests/snap_spacing.rs b/crates/grida-math2/tests/snap_spacing.rs new file mode 100644 index 0000000000..6514af5ad9 --- /dev/null +++ b/crates/grida-math2/tests/snap_spacing.rs @@ -0,0 +1,90 @@ +use math2::{DistributionGeometry1D, Range, SnapProjectionPoint, plot_distribution_geometry}; + +fn range(a: f32, b: f32) -> Range { + [a, b] +} + +#[test] +fn empty_loops_when_no_gap() { + let ranges = vec![range(0.0, 10.0), range(5.0, 15.0)]; + let result = plot_distribution_geometry(&ranges, None); + assert!(result.loops.is_empty()); + assert!(result.gaps.is_empty()); + assert!(result.a.is_empty()); + assert!(result.b.is_empty()); +} + +#[test] +fn single_gap_non_overlapping() { + let ranges = vec![range(0.0, 10.0), range(20.0, 30.0)]; + let result = plot_distribution_geometry(&ranges, None); + assert_eq!(result.loops, vec![[0, 1]]); + assert_eq!(result.gaps, vec![10.0]); + assert_eq!( + result.a[0], + vec![SnapProjectionPoint { + p: 40.0, + o: 30.0, + fwd: 0 + }] + ); + assert_eq!( + result.b[0], + vec![SnapProjectionPoint { + p: -10.0, + o: 0.0, + fwd: 0 + }] + ); +} + +#[test] +fn multiple_loops_more_ranges() { + let ranges = vec![range(0.0, 10.0), range(20.0, 30.0), range(40.0, 50.0)]; + let result = plot_distribution_geometry(&ranges, None); + assert_eq!(result.loops.len(), 3); + assert_eq!(result.gaps.len(), 3); + assert_eq!(result.a.len(), 3); + assert_eq!(result.b.len(), 3); +} + +#[test] +fn center_based_when_agent_smaller_than_gap() { + let ranges = vec![range(0.0, 10.0), range(20.0, 30.0)]; + let result = plot_distribution_geometry(&ranges, Some(5.0)); + assert_eq!(result.a[0].len(), 2); + assert_eq!(result.b[0].len(), 2); + let center_a = result.a[0][1]; + let center_b = result.b[0][1]; + assert_eq!(center_a.fwd, -1); + assert_eq!(center_b.fwd, -1); +} + +#[test] +fn skip_center_based_when_agent_ge_gap() { + let ranges = vec![range(0.0, 10.0), range(20.0, 30.0)]; + let result = plot_distribution_geometry(&ranges, Some(10.0)); + assert_eq!(result.a[0].len(), 1); + assert_eq!(result.b[0].len(), 1); +} + +#[test] +fn sizes_match_complex_inputs() { + let ranges = vec![ + range(0.0, 15.0), + range(10.0, 25.0), + range(25.0, 30.0), + range(40.0, 55.0), + range(50.0, 65.0), + range(80.0, 95.0), + range(150.0, 165.0), + ]; + let result = plot_distribution_geometry(&ranges, Some(10.0)); + assert_eq!(result.loops.len(), result.a.len()); + assert_eq!(result.loops.len(), result.b.len()); + for i in 0..result.loops.len() { + assert!(result.a.get(i).is_some()); + assert!(result.b.get(i).is_some()); + } + assert_eq!(result.loops.len(), result.gaps.len()); +} diff --git a/crates/grida-math2/tests/transform.rs b/crates/grida-math2/tests/transform.rs new file mode 100644 index 0000000000..8340701402 --- /dev/null +++ b/crates/grida-math2/tests/transform.rs @@ -0,0 +1,25 @@ +use math2::transform::AffineTransform; + +fn transforms_close(a: &AffineTransform, b: &AffineTransform) { + for i in 0..2 { + for j in 0..3 { + assert!((a.matrix[i][j] - b.matrix[i][j]).abs() < 1e-6); + } + } +} + +#[test] +fn invert_translation() { + let t = AffineTransform::new(5.0, -3.0, 0.0); + let inv = t.inverse().unwrap(); + let res = inv.compose(&t); + transforms_close(&res, &AffineTransform::identity()); +} + +#[test] +fn invert_rotation() { + let t = AffineTransform::from_rotatation(45.0); + let inv = t.inverse().unwrap(); + let res = inv.compose(&t); + transforms_close(&res, &AffineTransform::identity()); +} diff --git a/crates/grida-math2/tests/ui.rs b/crates/grida-math2/tests/ui.rs new file mode 100644 index 0000000000..d6d84fdc9f --- /dev/null +++ b/crates/grida-math2/tests/ui.rs @@ -0,0 +1,54 @@ +use math2::{ + format_number, normalize_line, + transform::AffineTransform, + transform_line, transform_point, + ui::{Line, Point}, +}; + +#[test] +fn transform_point_translation() { + let p = Point { + label: None, + x: 1.0, + y: 2.0, + }; + let t = AffineTransform::new(3.0, 4.0, 0.0); + let res = transform_point(&p, &t); + assert_eq!(res.x, 4.0); + assert_eq!(res.y, 6.0); +} + +#[test] +fn transform_line_translation() { + let line = Line { + label: None, + x1: 0.0, + y1: 0.0, + x2: 1.0, + y2: 1.0, + }; + let t = AffineTransform::new(1.0, 2.0, 0.0); + let res = transform_line(&line, &t); + assert_eq!((res.x1, res.y1, res.x2, res.y2), (1.0, 2.0, 2.0, 3.0)); +} + +#[test] +fn normalize_line_swaps_when_needed() { + let line = Line { + label: None, + x1: 5.0, + y1: 0.0, + x2: 3.0, + y2: 1.0, + }; + let res = normalize_line(&line); + assert_eq!((res.x1, res.y1, res.x2, res.y2), (3.0, 1.0, 5.0, 0.0)); +} + +#[test] +fn format_number_examples() { + assert_eq!(format_number(1.0, 1), "1"); + assert_eq!(format_number(1.2222, 1), "1.2"); + assert_eq!(format_number(9.0001, 2), "9"); + assert_eq!(format_number(9.1234, 2), "9.12"); +} diff --git a/crates/grida-math2/tests/utils.rs b/crates/grida-math2/tests/utils.rs new file mode 100644 index 0000000000..688aec061e --- /dev/null +++ b/crates/grida-math2/tests/utils.rs @@ -0,0 +1,21 @@ +use math2::{nearest, quantize}; + +#[test] +fn quantize_basic() { + assert_eq!(quantize(15.0, 10.0), 20.0); + assert_eq!(quantize(14.0, 10.0), 10.0); + assert_eq!(quantize(0.1123, 0.1), 0.1); +} + +#[test] +#[should_panic] +fn quantize_invalid_step() { + quantize(15.0, 0.0); +} + +#[test] +fn nearest_value() { + let vals = [10.0, 20.0, 30.0]; + assert_eq!(nearest(18.0, &vals), 20.0); + assert_eq!(nearest(-5.0, &vals), 10.0); +} diff --git a/crates/grida-math2/tests/vector2.rs b/crates/grida-math2/tests/vector2.rs new file mode 100644 index 0000000000..4de1d476f2 --- /dev/null +++ b/crates/grida-math2/tests/vector2.rs @@ -0,0 +1,46 @@ +use math2::vector2::{Vector2, add, angle, intersection, intersects, multiply, rotate, sub}; + +#[test] +fn add_multiple() { + let v1: Vector2 = [1.0, 2.0]; + let v2: Vector2 = [3.0, 4.0]; + let v3: Vector2 = [-1.0, -1.0]; + assert_eq!(add(&[v1, v2, v3]), [3.0, 5.0]); +} + +#[test] +fn sub_two() { + let v1: Vector2 = [5.0, 7.0]; + let v2: Vector2 = [2.0, 3.0]; + assert_eq!(sub(&[v1, v2]), [3.0, 4.0]); +} + +#[test] +fn multiply_two() { + let v1: Vector2 = [2.0, 3.0]; + let v2: Vector2 = [4.0, 5.0]; + assert_eq!(multiply(&[v1, v2]), [8.0, 15.0]); +} + +#[test] +fn angle_first_quadrant() { + let origin: Vector2 = [0.0, 0.0]; + let p: Vector2 = [1.0, 1.0]; + let a = angle(origin, p); + assert!((a - 45.0).abs() < 0.001); +} + +#[test] +fn rotate_90() { + let v: Vector2 = [1.0, 0.0]; + let r = rotate(v, 90.0); + assert!((r[0]).abs() < 1e-6 && (r[1] - 1.0).abs() < 1e-6); +} + +#[test] +fn segment_intersection() { + let a: Vector2 = [1.0, 5.0]; + let b: Vector2 = [4.0, 8.0]; + assert!(intersects(a, b)); + assert_eq!(intersection(a, b), Some([4.0, 5.0])); +} diff --git a/crates/grida-math2/tests/viewport.rs b/crates/grida-math2/tests/viewport.rs new file mode 100644 index 0000000000..538b00f985 --- /dev/null +++ b/crates/grida-math2/tests/viewport.rs @@ -0,0 +1,65 @@ +use math2::{Rectangle, viewport_transform_to_fit}; + +fn rect(x: f32, y: f32, w: f32, h: f32) -> Rectangle { + Rectangle { + x, + y, + width: w, + height: h, + } +} + +#[test] +fn identity_when_target_zero_sized() { + let viewport = rect(0.0, 0.0, 800.0, 600.0); + let zero = rect(100.0, 100.0, 0.0, 0.0); + let t = viewport_transform_to_fit(viewport, zero, 0.0); + assert_eq!(t.matrix, [[1.0, 0.0, viewport.x], [0.0, 1.0, viewport.y]]); +} + +#[test] +fn identity_when_effective_viewport_negative() { + let viewport = rect(0.0, 0.0, 100.0, 100.0); + let target = rect(0.0, 0.0, 50.0, 50.0); + let t = viewport_transform_to_fit(viewport, target, 200.0); + assert_eq!(t.matrix, [[1.0, 0.0, viewport.x], [0.0, 1.0, viewport.y]]); +} + +#[test] +fn fits_larger_target_scale_lt_one() { + let viewport = rect(0.0, 0.0, 400.0, 300.0); + let target = rect(10.0, 20.0, 600.0, 400.0); + let t = viewport_transform_to_fit(viewport, target, 0.0); + assert!((t.matrix[0][0] - 0.666).abs() < 0.01); + assert!((t.matrix[1][1] - 0.666).abs() < 0.01); + let tx = t.matrix[0][2]; + let ty = t.matrix[1][2]; + assert!(!tx.is_nan()); + assert!(!ty.is_nan()); +} + +#[test] +fn fits_smaller_target_scale_ge_one() { + let viewport = rect(0.0, 0.0, 400.0, 300.0); + let target = rect(10.0, 10.0, 200.0, 100.0); + let t = viewport_transform_to_fit(viewport, target, 0.0); + assert!(t.matrix[0][0] >= 1.0); +} + +#[test] +fn applies_uniform_margin() { + let viewport = rect(0.0, 0.0, 800.0, 600.0); + let target = rect(0.0, 0.0, 400.0, 300.0); + let t = viewport_transform_to_fit(viewport, target, 50.0); + assert!((t.matrix[0][0] - 1.6667).abs() < 0.01); + assert!((t.matrix[1][1] - 1.6667).abs() < 0.01); +} + +#[test] +fn applies_per_side_margin() { + let viewport = rect(0.0, 0.0, 800.0, 600.0); + let target = rect(100.0, 100.0, 600.0, 300.0); + let t = viewport_transform_to_fit(viewport, target, [50.0, 20.0, 50.0, 20.0]); + assert!((t.matrix[0][0] - 1.2667).abs() < 0.01); + assert!((t.matrix[1][1] - 1.2667).abs() < 0.01); +} diff --git a/crates/grida/Cargo.toml b/crates/grida/Cargo.toml new file mode 100644 index 0000000000..50f21628ac --- /dev/null +++ b/crates/grida/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "grida" +version = "0.0.0" +license = "Apache-2.0" +description = "reserved for future use by grida" +edition = "2021" + + +[features] +default = ["skia-safe/gl"] + +[dependencies] +skia-safe = { version = "0.86.0", features = ["gpu", "gl", "textlayout"] } +gl = "0.14.0" diff --git a/crates/grida/src/main.rs b/crates/grida/src/main.rs new file mode 100644 index 0000000000..487a27e1bb --- /dev/null +++ b/crates/grida/src/main.rs @@ -0,0 +1,135 @@ +use std::boxed::Box; + +use skia_safe::{ + Color, Paint, PaintStyle, Surface, + gpu::{self, DirectContext, gl::FramebufferInfo}, +}; + +extern "C" { + pub fn emscripten_GetProcAddress( + name: *const ::std::os::raw::c_char, + ) -> *const ::std::os::raw::c_void; +} + +struct GpuState { + context: DirectContext, + framebuffer_info: FramebufferInfo, +} + +/// This struct holds the state of the Rust application between JS calls. +/// +/// It is created by [init] and passed to the other exported functions. Note that rust-skia data +/// structures are not thread safe, so a state must not be shared between different Web Workers. +pub struct State { + gpu_state: GpuState, + surface: Surface, +} + +impl State { + fn new(gpu_state: GpuState, surface: Surface) -> Self { + State { gpu_state, surface } + } + + fn set_surface(&mut self, surface: Surface) { + self.surface = surface; + } +} + +/// Load GL functions pointers from JavaScript so we can call OpenGL functions from Rust. +/// +/// This only needs to be done once. +fn init_gl() { + unsafe { + gl::load_with(|addr| { + let addr = std::ffi::CString::new(addr).unwrap(); + emscripten_GetProcAddress(addr.into_raw() as *const _) as *const _ + }); + } +} + +/// Create the GPU state from the JavaScript WebGL context. +/// +/// This needs to be done once per WebGL context. +fn create_gpu_state() -> GpuState { + let interface = skia_safe::gpu::gl::Interface::new_native().unwrap(); + let context = skia_safe::gpu::direct_contexts::make_gl(interface, None).unwrap(); + let framebuffer_info = { + let mut fboid: gl::types::GLint = 0; + unsafe { gl::GetIntegerv(gl::FRAMEBUFFER_BINDING, &mut fboid) }; + + FramebufferInfo { + fboid: fboid.try_into().unwrap(), + format: skia_safe::gpu::gl::Format::RGBA8.into(), + protected: skia_safe::gpu::Protected::No, + } + }; + + GpuState { + context, + framebuffer_info, + } +} + +/// Create the Skia surface that will be used for rendering. +fn create_surface(gpu_state: &mut GpuState, width: i32, height: i32) -> Surface { + let backend_render_target = + gpu::backend_render_targets::make_gl((width, height), 1, 8, gpu_state.framebuffer_info); + + gpu::surfaces::wrap_backend_render_target( + &mut gpu_state.context, + &backend_render_target, + skia_safe::gpu::SurfaceOrigin::BottomLeft, + skia_safe::ColorType::RGBA8888, + None, + None, + ) + .unwrap() +} + +fn render_circle(surface: &mut Surface, x: f32, y: f32, radius: f32) { + let mut paint = Paint::default(); + paint.set_style(PaintStyle::Fill); + paint.set_color(Color::BLACK); + paint.set_anti_alias(true); + surface.canvas().draw_circle((x, y), radius, &paint); +} + +/// Initialize the renderer. +/// +/// This is called from JS after the WebGL context has been created. +#[no_mangle] +pub extern "C" fn init(width: i32, height: i32) -> Box { + let mut gpu_state = create_gpu_state(); + let surface = create_surface(&mut gpu_state, width, height); + let state = State::new(gpu_state, surface); + Box::new(state) +} + +/// Resize the Skia surface +/// +/// This is called from JS when the window is resized. +/// # Safety +#[no_mangle] +pub unsafe extern "C" fn resize_surface(state: *mut State, width: i32, height: i32) { + let state = unsafe { state.as_mut() }.expect("got an invalid state pointer"); + let surface = create_surface(&mut state.gpu_state, width, height); + state.set_surface(surface); +} + +/// Draw a black circle at the specified coordinates. +/// # Safety +#[no_mangle] +pub unsafe extern "C" fn draw_circle(state: *mut State, x: i32, y: i32) { + let state = unsafe { state.as_mut() }.expect("got an invalid state pointer"); + //state.surface.canvas().clear(Color::WHITE); + render_circle(&mut state.surface, x as f32, y as f32, 50.); + state + .gpu_state + .context + .flush_and_submit_surface(&mut state.surface, None); +} + +/// The main function is called by emscripten when the WASM object is created. +fn main() { + init_gl(); +} diff --git a/crates/grida/web/index.html b/crates/grida/web/index.html new file mode 100644 index 0000000000..5210c540ad --- /dev/null +++ b/crates/grida/web/index.html @@ -0,0 +1,15 @@ + + + + + + rust-skia Wasm sample + + + + + + + + + \ No newline at end of file diff --git a/crates/grida/web/main.js b/crates/grida/web/main.js new file mode 100644 index 0000000000..2411fb2064 --- /dev/null +++ b/crates/grida/web/main.js @@ -0,0 +1,53 @@ +/** + * Make a canvas element fit to the display window. + */ +function resizeCanvasToDisplaySize(canvas) { + const width = canvas.clientWidth | 1; + const height = canvas.clientHeight | 1; + if (canvas.width !== width || canvas.height !== height) { + canvas.width = width; + canvas.height = height; + return true; + } + return false; +} + +// This loads and initialize our WASM module +createRustSkiaModule().then((RustSkia) => { + // Create the WebGL context + let context; + const canvas = document.querySelector("#glcanvas"); + context = canvas.getContext("webgl2", { + antialias: true, + depth: true, + stencil: true, + alpha: true, + }); + + // Register the context with emscripten + handle = RustSkia.GL.registerContext(context, { majorVersion: 2 }); + RustSkia.GL.makeContextCurrent(handle); + + // Fit the canvas to the viewport + resizeCanvasToDisplaySize(canvas); + + // Initialize Skia + const state = RustSkia._init(canvas.width, canvas.height); + + // Draw a circle that follows the mouse pointer + window.addEventListener("mousemove", (event) => { + const canvasPos = canvas.getBoundingClientRect(); + RustSkia._draw_circle( + state, + event.clientX - canvasPos.x, + event.clientY - canvasPos.y + ); + }); + + // Make canvas size stick to the window size + window.addEventListener("resize", () => { + if (resizeCanvasToDisplaySize(canvas)) { + RustSkia._resize_surface(state, canvas.width, canvas.height); + } + }); +}); diff --git a/editor/app/(dev)/canvas/experimental/skia/page.tsx b/editor/app/(dev)/canvas/experimental/skia/page.tsx new file mode 100644 index 0000000000..43ebbe29c4 --- /dev/null +++ b/editor/app/(dev)/canvas/experimental/skia/page.tsx @@ -0,0 +1,130 @@ +"use client"; +import * as React from "react"; +import { + AutoInitialFitTransformer, + EditorSurface, + StandaloneDocumentEditor, + StandaloneSceneBackground, + StandaloneSceneContent, + useEditor, + ViewportRoot, +} from "@/grida-canvas-react"; +import { WindowCurrentEditorProvider } from "@/grida-canvas-react/devtools/global-api-host"; +import { Hotkeys } from "@/grida-canvas-react/viewport/hotkeys"; + +// const imageNode: grida.program.nodes.ImageNode = { +// type: "image", +// id: "1", +// name: "Image", +// active: true, +// locked: false, +// style: {}, +// opacity: 1, +// rotation: 0, +// zIndex: 0, +// position: "absolute", +// left: 300, +// top: 300, +// width: 100, +// height: 100, +// fit: "contain", +// src: "/images/abstract-placeholder.jpg", +// cornerRadius: 0, +// }; + +// const textNode: grida.program.nodes.TextNode = { +// type: "text", +// id: "1", +// name: "Text", +// active: true, +// locked: false, +// style: {}, +// fontFamily: "Arial", +// opacity: 1, +// rotation: 0, +// zIndex: 0, +// position: "absolute", +// width: 200, +// height: 100, +// textAlign: "left", +// textAlignVertical: "top", +// textDecoration: "none", +// fontSize: 16, +// fontWeight: 100, +// text: "Hello, world!", +// }; + +// const lineNode: grida.program.nodes.LineNode = { +// type: "line", +// id: "1", +// name: "Line", +// active: true, +// locked: false, +// height: 0, +// top: 50, +// left: 100, +// position: "absolute", +// stroke: { type: "solid", color: { r: 0, g: 0, b: 0, a: 1 } }, +// strokeWidth: 1, +// strokeCap: "butt", +// width: 200, +// opacity: 1, +// zIndex: 0, +// rotation: 0, +// }; + +export default function SkiaCanvasKitExperimentalPage() { + const canvasRef = React.useRef(null); + // const rendererRef = React.useRef(null); + const editor = useEditor(); + + React.useEffect(() => { + // if (canvasRef.current && !rendererRef.current) { + // const renderer = new CanvasKitRenderer(canvasRef.current); + // rendererRef.current = renderer; + // editor.subscribeWithSelector( + // (state) => state.document.nodes, + // (editor, selected) => { + // rendererRef.current?.setDocument( + // selected, + // editor.state.document.scenes["main"].children[0] + // ); + // } + // ); + // } + }, []); + + return ( +
+
+

+ Grida Canvas SKIA BACKEND +

+
+
+ + +
+
+ ); +} diff --git a/editor/grida-canvas-react/use-editor.tsx b/editor/grida-canvas-react/use-editor.tsx index aee00a242c..dc03346629 100644 --- a/editor/grida-canvas-react/use-editor.tsx +++ b/editor/grida-canvas-react/use-editor.tsx @@ -6,8 +6,29 @@ import { useSyncExternalStoreWithSelector } from "use-sync-external-store/shim/w import type { editor } from "@/grida-canvas"; import deepEqual from "fast-deep-equal/es6/react.js"; -export function useEditor(init: editor.state.IEditorStateInit) { - const [_editor] = React.useState(new Editor(init)); +export function useEditor(init?: editor.state.IEditorStateInit) { + const [_editor] = React.useState( + new Editor( + init ?? { + debug: false, + document: { + nodes: {}, + entry_scene_id: "main", + scenes: { + main: { + type: "scene", + id: "main", + name: "main", + children: [], + guides: [], + constraints: { children: "multiple" }, + }, + }, + }, + editable: true, + } + ) + ); const editor = useSyncExternalStore( _editor.subscribe.bind(_editor), diff --git a/editor/grida-canvas-react/viewport/hotkeys.tsx b/editor/grida-canvas-react/viewport/hotkeys.tsx index d36616b9b2..4b2994564c 100644 --- a/editor/grida-canvas-react/viewport/hotkeys.tsx +++ b/editor/grida-canvas-react/viewport/hotkeys.tsx @@ -927,3 +927,9 @@ export function useEditorHotKeys() { toast.error("[eject component] is not implemented yet"); }); } + +export function Hotkeys() { + useEditorHotKeys(); + + return null; +} diff --git a/editor/public/examples/canvas/instagram-post-01.grida b/editor/public/examples/canvas/instagram-post-01.grida index 2b099a2388..08c82b5ef3 100644 --- a/editor/public/examples/canvas/instagram-post-01.grida +++ b/editor/public/examples/canvas/instagram-post-01.grida @@ -65,7 +65,7 @@ "active": true, "locked": false, "rotation": 0, - "opacity": 0.699999988079071, + "opacity": 0.7, "zIndex": 0, "type": "text", "text": "# our favorite question", @@ -145,7 +145,7 @@ "textAlign": "left", "textAlignVertical": "top", "textDecoration": "none", - "lineHeight": 1.0909091186523439, + "lineHeight": 1.01, "letterSpacing": 0, "fontSize": 44, "fontFamily": "Inter", @@ -157,7 +157,7 @@ "active": true, "locked": false, "rotation": 0, - "opacity": 0.699999988079071, + "opacity": 0.7, "zIndex": 0, "type": "text", "text": "1 of 10", @@ -179,7 +179,7 @@ "textAlign": "left", "textAlignVertical": "top", "textDecoration": "none", - "lineHeight": 1.4285714721679688, + "lineHeight": 1.43, "letterSpacing": 0, "fontSize": 14, "fontFamily": "Inter", @@ -191,7 +191,7 @@ "active": true, "locked": false, "rotation": 0, - "opacity": 0.699999988079071, + "opacity": 0.7, "zIndex": 0, "type": "text", "text": "getting started", @@ -213,7 +213,7 @@ "textAlign": "left", "textAlignVertical": "top", "textDecoration": "none", - "lineHeight": 1.4285714721679688, + "lineHeight": 1.43, "letterSpacing": 1.5, "fontSize": 14, "fontFamily": "Inter", @@ -247,7 +247,7 @@ "textAlign": "right", "textAlignVertical": "top", "textDecoration": "none", - "lineHeight": 1.4285714721679688, + "lineHeight": 1.43, "letterSpacing": 1.5, "fontSize": 14, "fontFamily": "Inter", @@ -311,10 +311,10 @@ "zIndex": 0, "type": "vector", "position": "absolute", - "left": 1.6666641235351562, - "top": 1.6666641235351562, - "width": 16.666667938232422, - "height": 16.666667938232422, + "left": 1.67, + "top": 1.67, + "width": 16.67, + "height": 16.67, "fill": { "type": "solid", "color": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bce6590ab6..97fa42f974 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -34,7 +34,7 @@ importers: version: 3.5.3 ts-jest: specifier: ^29.3.2 - version: 29.3.4(@babel/core@7.27.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.27.4))(esbuild@0.25.4)(jest@29.7.0(@types/node@22.15.28)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.15.28)(typescript@5.8.3)))(typescript@5.8.3) + version: 29.3.4(@babel/core@7.27.1)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.27.1))(esbuild@0.25.4)(jest@29.7.0(@types/node@22.15.28)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.15.28)(typescript@5.8.3)))(typescript@5.8.3) tsup: specifier: ^8.4.0 version: 8.5.0(jiti@2.4.2)(postcss@8.5.4)(typescript@5.8.3)(yaml@2.7.0) @@ -49,7 +49,7 @@ importers: dependencies: '@next/third-parties': specifier: 15.3.2 - version: 15.3.2(next@15.3.2(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(babel-plugin-macros@3.1.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0) + version: 15.3.2(next@15.3.2(@babel/core@7.27.1)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(babel-plugin-macros@3.1.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0) '@react-three/drei': specifier: ^10.0.7 version: 10.1.2(@react-three/fiber@9.1.2(@types/react@19.1.3)(immer@9.0.21)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(three@0.170.0))(@types/react@19.1.3)(@types/three@0.170.0)(immer@9.0.21)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(three@0.170.0) @@ -64,7 +64,7 @@ importers: version: 12.15.0(@emotion/is-prop-valid@1.3.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) next: specifier: 15.3.2 - version: 15.3.2(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(babel-plugin-macros@3.1.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + version: 15.3.2(@babel/core@7.27.1)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(babel-plugin-macros@3.1.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) react: specifier: 19.0.0 version: 19.0.0 @@ -211,7 +211,7 @@ importers: version: 0.511.0(react@19.0.0) next: specifier: 15.3.2 - version: 15.3.2(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(babel-plugin-macros@3.1.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + version: 15.3.2(@babel/core@7.27.1)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(babel-plugin-macros@3.1.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) pdfjs-dist: specifier: 4.8.69 version: 4.8.69 @@ -259,6 +259,10 @@ importers: specifier: ^5 version: 5.8.3 + crates/grida-canvas: {} + + crates/grida-math2: {} + database: dependencies: type-fest: @@ -13924,45 +13928,21 @@ snapshots: '@babel/core': 7.27.1 '@babel/helper-plugin-utils': 7.26.5 - '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.27.4)': - dependencies: - '@babel/core': 7.27.4 - '@babel/helper-plugin-utils': 7.26.5 - optional: true - '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.27.1)': dependencies: '@babel/core': 7.27.1 '@babel/helper-plugin-utils': 7.26.5 - '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.27.4)': - dependencies: - '@babel/core': 7.27.4 - '@babel/helper-plugin-utils': 7.26.5 - optional: true - '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.27.1)': dependencies: '@babel/core': 7.27.1 '@babel/helper-plugin-utils': 7.26.5 - '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.27.4)': - dependencies: - '@babel/core': 7.27.4 - '@babel/helper-plugin-utils': 7.26.5 - optional: true - '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.27.1)': dependencies: '@babel/core': 7.27.1 '@babel/helper-plugin-utils': 7.26.5 - '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.27.4)': - dependencies: - '@babel/core': 7.27.4 - '@babel/helper-plugin-utils': 7.26.5 - optional: true - '@babel/plugin-syntax-dynamic-import@7.8.3(@babel/core@7.27.1)': dependencies: '@babel/core': 7.27.1 @@ -13978,34 +13958,16 @@ snapshots: '@babel/core': 7.27.1 '@babel/helper-plugin-utils': 7.26.5 - '@babel/plugin-syntax-import-attributes@7.26.0(@babel/core@7.27.4)': - dependencies: - '@babel/core': 7.27.4 - '@babel/helper-plugin-utils': 7.26.5 - optional: true - '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.27.1)': dependencies: '@babel/core': 7.27.1 '@babel/helper-plugin-utils': 7.26.5 - '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.27.4)': - dependencies: - '@babel/core': 7.27.4 - '@babel/helper-plugin-utils': 7.26.5 - optional: true - '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.27.1)': dependencies: '@babel/core': 7.27.1 '@babel/helper-plugin-utils': 7.26.5 - '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.27.4)': - dependencies: - '@babel/core': 7.27.4 - '@babel/helper-plugin-utils': 7.26.5 - optional: true - '@babel/plugin-syntax-jsx@7.25.9(@babel/core@7.27.1)': dependencies: '@babel/core': 7.27.1 @@ -14016,89 +13978,41 @@ snapshots: '@babel/core': 7.27.1 '@babel/helper-plugin-utils': 7.26.5 - '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.27.4)': - dependencies: - '@babel/core': 7.27.4 - '@babel/helper-plugin-utils': 7.26.5 - optional: true - '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.27.1)': dependencies: '@babel/core': 7.27.1 '@babel/helper-plugin-utils': 7.26.5 - '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.27.4)': - dependencies: - '@babel/core': 7.27.4 - '@babel/helper-plugin-utils': 7.26.5 - optional: true - '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.27.1)': dependencies: '@babel/core': 7.27.1 '@babel/helper-plugin-utils': 7.26.5 - '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.27.4)': - dependencies: - '@babel/core': 7.27.4 - '@babel/helper-plugin-utils': 7.26.5 - optional: true - '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.27.1)': dependencies: '@babel/core': 7.27.1 '@babel/helper-plugin-utils': 7.26.5 - '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.27.4)': - dependencies: - '@babel/core': 7.27.4 - '@babel/helper-plugin-utils': 7.26.5 - optional: true - '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.27.1)': dependencies: '@babel/core': 7.27.1 '@babel/helper-plugin-utils': 7.26.5 - '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.27.4)': - dependencies: - '@babel/core': 7.27.4 - '@babel/helper-plugin-utils': 7.26.5 - optional: true - '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.27.1)': dependencies: '@babel/core': 7.27.1 '@babel/helper-plugin-utils': 7.26.5 - '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.27.4)': - dependencies: - '@babel/core': 7.27.4 - '@babel/helper-plugin-utils': 7.26.5 - optional: true - '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.27.1)': dependencies: '@babel/core': 7.27.1 '@babel/helper-plugin-utils': 7.26.5 - '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.27.4)': - dependencies: - '@babel/core': 7.27.4 - '@babel/helper-plugin-utils': 7.26.5 - optional: true - '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.27.1)': dependencies: '@babel/core': 7.27.1 '@babel/helper-plugin-utils': 7.26.5 - '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.27.4)': - dependencies: - '@babel/core': 7.27.4 - '@babel/helper-plugin-utils': 7.26.5 - optional: true - '@babel/plugin-syntax-typescript@7.25.9(@babel/core@7.27.1)': dependencies: '@babel/core': 7.27.1 @@ -16785,6 +16699,12 @@ snapshots: '@next/swc-win32-x64-msvc@15.3.2': optional: true + '@next/third-parties@15.3.2(next@15.3.2(@babel/core@7.27.1)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(babel-plugin-macros@3.1.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)': + dependencies: + next: 15.3.2(@babel/core@7.27.1)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(babel-plugin-macros@3.1.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + react: 19.0.0 + third-party-capital: 1.0.20 + '@next/third-parties@15.3.2(next@15.3.2(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(babel-plugin-macros@3.1.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)': dependencies: next: 15.3.2(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(babel-plugin-macros@3.1.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) @@ -20421,20 +20341,6 @@ snapshots: transitivePeerDependencies: - supports-color - babel-jest@29.7.0(@babel/core@7.27.4): - dependencies: - '@babel/core': 7.27.4 - '@jest/transform': 29.7.0 - '@types/babel__core': 7.20.5 - babel-plugin-istanbul: 6.1.1 - babel-preset-jest: 29.6.3(@babel/core@7.27.4) - chalk: 4.1.2 - graceful-fs: 4.2.11 - slash: 3.0.0 - transitivePeerDependencies: - - supports-color - optional: true - babel-loader@9.2.1(@babel/core@7.27.1)(webpack@5.98.0(esbuild@0.25.4)): dependencies: '@babel/core': 7.27.1 @@ -20544,39 +20450,12 @@ snapshots: '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.27.1) '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.27.1) - babel-preset-current-node-syntax@1.1.0(@babel/core@7.27.4): - dependencies: - '@babel/core': 7.27.4 - '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.27.4) - '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.27.4) - '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.27.4) - '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.27.4) - '@babel/plugin-syntax-import-attributes': 7.26.0(@babel/core@7.27.4) - '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.27.4) - '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.27.4) - '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.27.4) - '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.27.4) - '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.27.4) - '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.27.4) - '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.27.4) - '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.27.4) - '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.27.4) - '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.27.4) - optional: true - babel-preset-jest@29.6.3(@babel/core@7.27.1): dependencies: '@babel/core': 7.27.1 babel-plugin-jest-hoist: 29.6.3 babel-preset-current-node-syntax: 1.1.0(@babel/core@7.27.1) - babel-preset-jest@29.6.3(@babel/core@7.27.4): - dependencies: - '@babel/core': 7.27.4 - babel-plugin-jest-hoist: 29.6.3 - babel-preset-current-node-syntax: 1.1.0(@babel/core@7.27.4) - optional: true - bail@2.0.2: {} balanced-match@0.4.2: {} @@ -25132,6 +25011,33 @@ snapshots: react: 19.0.0 react-dom: 19.0.0(react@19.0.0) + next@15.3.2(@babel/core@7.27.1)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(babel-plugin-macros@3.1.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0): + dependencies: + '@next/env': 15.3.2 + '@swc/counter': 0.1.3 + '@swc/helpers': 0.5.15 + busboy: 1.6.0 + caniuse-lite: 1.0.30001717 + postcss: 8.4.31 + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + styled-jsx: 5.1.6(@babel/core@7.27.1)(babel-plugin-macros@3.1.0)(react@19.0.0) + optionalDependencies: + '@next/swc-darwin-arm64': 15.3.2 + '@next/swc-darwin-x64': 15.3.2 + '@next/swc-linux-arm64-gnu': 15.3.2 + '@next/swc-linux-arm64-musl': 15.3.2 + '@next/swc-linux-x64-gnu': 15.3.2 + '@next/swc-linux-x64-musl': 15.3.2 + '@next/swc-win32-arm64-msvc': 15.3.2 + '@next/swc-win32-x64-msvc': 15.3.2 + '@opentelemetry/api': 1.9.0 + '@playwright/test': 1.52.0 + sharp: 0.34.1 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros + next@15.3.2(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(babel-plugin-macros@3.1.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0): dependencies: '@next/env': 15.3.2 @@ -27679,6 +27585,14 @@ snapshots: dependencies: inline-style-parser: 0.2.4 + styled-jsx@5.1.6(@babel/core@7.27.1)(babel-plugin-macros@3.1.0)(react@19.0.0): + dependencies: + client-only: 0.0.1 + react: 19.0.0 + optionalDependencies: + '@babel/core': 7.27.1 + babel-plugin-macros: 3.1.0 + styled-jsx@5.1.6(@babel/core@7.27.4)(babel-plugin-macros@3.1.0)(react@19.0.0): dependencies: client-only: 0.0.1 @@ -28004,7 +27918,7 @@ snapshots: ts-interface-checker@0.1.13: {} - ts-jest@29.3.4(@babel/core@7.27.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.27.4))(esbuild@0.25.4)(jest@29.7.0(@types/node@22.15.28)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.15.28)(typescript@5.8.3)))(typescript@5.8.3): + ts-jest@29.3.4(@babel/core@7.27.1)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.27.1))(esbuild@0.25.4)(jest@29.7.0(@types/node@22.15.28)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.15.28)(typescript@5.8.3)))(typescript@5.8.3): dependencies: bs-logger: 0.2.6 ejs: 3.1.10 @@ -28019,10 +27933,10 @@ snapshots: typescript: 5.8.3 yargs-parser: 21.1.1 optionalDependencies: - '@babel/core': 7.27.4 + '@babel/core': 7.27.1 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - babel-jest: 29.7.0(@babel/core@7.27.4) + babel-jest: 29.7.0(@babel/core@7.27.1) esbuild: 0.25.4 ts-node@10.9.2(@types/node@22.15.28)(typescript@5.8.3): diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 4f28eba0ff..d5204ef516 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,5 +2,6 @@ packages: - "editor" - "database" - "apps/*" + - "crates/*" - "packages/*" - "packages/lib/*" diff --git a/turbo.json b/turbo.json index 3ba63147cc..d3124f9d1a 100644 --- a/turbo.json +++ b/turbo.json @@ -6,7 +6,13 @@ }, "build": { "dependsOn": ["^build"], - "outputs": [".next/**", "!.next/cache/**", "build/**", "dist/**"] + "outputs": [ + ".next/**", + "!.next/cache/**", + "build/**", + "dist/**", + "target/release/**" + ] }, "typecheck": { "dependsOn": ["^typecheck"]