From 066f7f7f74e197f22c31174242e57fab9657a38f Mon Sep 17 00:00:00 2001 From: Emil Widlund Date: Fri, 31 May 2024 14:50:49 +0200 Subject: [PATCH] implement gradient support --- .../migration.sql | 2 + apps/web/prisma/schema.prisma | 3 +- .../Controls/ColorControl/ColorControl.tsx | 60 +++---------------- apps/web/src/utils.ts | 51 ++++++++++++++++ apps/web/src/windows/GradientWindow.tsx | 58 ++++++++++++++++++ apps/web/src/windows/HSVWindow.tsx | 4 +- apps/web/src/windows/index.tsx | 5 +- packages/nodes/src/color/Gradient/Gradient.ts | 56 +++++++++++++++++ packages/nodes/src/color/index.ts | 10 +++- packages/nodes/src/descriptions.ts | 1 + packages/nodes/src/types.ts | 1 + packages/schemas/src/color.ts | 37 ++++++++++++ packages/schemas/src/gradient.ts | 18 ++++++ packages/schemas/src/index.ts | 56 ++--------------- packages/schemas/src/utility.ts | 13 ++++ 15 files changed, 265 insertions(+), 110 deletions(-) create mode 100644 apps/web/prisma/migrations/20240531114647_add_gradient_type/migration.sql create mode 100644 apps/web/src/windows/GradientWindow.tsx create mode 100644 packages/nodes/src/color/Gradient/Gradient.ts create mode 100644 packages/schemas/src/color.ts create mode 100644 packages/schemas/src/gradient.ts create mode 100644 packages/schemas/src/utility.ts diff --git a/apps/web/prisma/migrations/20240531114647_add_gradient_type/migration.sql b/apps/web/prisma/migrations/20240531114647_add_gradient_type/migration.sql new file mode 100644 index 0000000..1088da4 --- /dev/null +++ b/apps/web/prisma/migrations/20240531114647_add_gradient_type/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "NodeType" ADD VALUE 'GRADIENT'; diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index 7726d0b..75634f6 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -1,5 +1,5 @@ generator client { - provider = "prisma-client-js" + provider = "prisma-client-js" } datasource db { @@ -108,6 +108,7 @@ enum NodeType { FROM_HSL TO_RGB FROM_RGB + GRADIENT IMAGE WEBCAM IMAGE_VARIATION_AI diff --git a/apps/web/src/components/Controls/ColorControl/ColorControl.tsx b/apps/web/src/components/Controls/ColorControl/ColorControl.tsx index 58a1b52..ee2a9b9 100644 --- a/apps/web/src/components/Controls/ColorControl/ColorControl.tsx +++ b/apps/web/src/components/Controls/ColorControl/ColorControl.tsx @@ -1,12 +1,5 @@ import { Input, Output } from '@bitspace/circuit'; import { observer } from 'mobx-react-lite'; -import { - ColorSchema, - HSLSchema, - HSVSchema, - HexSchema, - RGBSchema -} from '@bitspace/schemas'; import { ChangeEventHandler, FocusEventHandler, @@ -15,16 +8,10 @@ import { useMemo, useState } from 'react'; -import { z } from 'zod'; import { hsv2rgb } from '../../ColorPicker/ColorPicker.utils'; -import { hex, hsl, rgb } from 'color-convert'; +import { hsl, rgb } from 'color-convert'; import clsx from 'clsx'; - -export type HexColorSchemaType = z.infer>; -export type RGBColorSchemaType = z.infer>; -export type HSLColorSchemaType = z.infer>; -export type HSVColorSchemaType = z.infer>; -type ColorSchemaType = z.infer>; +import { ColorSchemaType, resolveColor } from '@/utils'; export interface ColorControlProps { port: Input | Output; @@ -67,51 +54,20 @@ export const ColorControl = observer(function ({ return '#000000'; }, [color]); - const resolveColor = useCallback( - (v: string): T => { - if (typeof color === 'object') { - if ('red' in color) { - const [red, green, blue] = hex.rgb(v); - - return RGBSchema().parse({ - red, - green, - blue - }) as T; - } else if ('hue' in color && 'luminance' in color) { - const [hue, saturation, luminance] = hex.hsl(v); - - return HSLSchema().parse({ - hue, - saturation: saturation / 100, - luminance: luminance / 100 - }) as T; - } else { - const [hue, saturation, value] = hex.hsv(v); - - return HSVSchema().parse({ - hue, - saturation: saturation / 100, - value: value / 100 - }) as T; - } - } else { - return HexSchema().parse(v) as T; - } - }, - [color] - ); - const handleBlur: FocusEventHandler = useCallback( e => { - onBlur?.(resolveColor(e.target.value)); + if (color) { + onBlur?.(resolveColor(e.target.value, color)); + } }, [onBlur, color] ); const handleChange: ChangeEventHandler = useCallback( e => { - port.next(resolveColor(e.target.value)); + if (color) { + port.next(resolveColor(e.target.value, color)); + } }, [port, color] ); diff --git a/apps/web/src/utils.ts b/apps/web/src/utils.ts index c1306fd..b5dfd56 100644 --- a/apps/web/src/utils.ts +++ b/apps/web/src/utils.ts @@ -1,4 +1,13 @@ +import { z } from 'zod'; import { Position } from './circuit'; +import { + ColorSchema, + HSLSchema, + HSVSchema, + HexSchema, + RGBSchema +} from '@bitspace/schemas'; +import { hex } from 'color-convert'; export const lerp = (a: number, b: number, t: number) => { return a * (1 - t) + b * t; @@ -7,6 +16,48 @@ export const lerp = (a: number, b: number, t: number) => { export const distanceBetween = (a: Position, b: Position) => { return Math.sqrt(Math.pow(b.x - a.x, 2) + Math.pow(b.y - a.y, 2)); }; + export const angleBetween = (a: Position, b: Position) => { return Math.atan2(b.x - a.x, b.y - a.y); }; + +export type HexColorSchemaType = z.infer>; +export type RGBColorSchemaType = z.infer>; +export type HSLColorSchemaType = z.infer>; +export type HSVColorSchemaType = z.infer>; +export type ColorSchemaType = z.infer>; + +export const resolveColor = ( + raw: string, + color: T +): T => { + if (typeof color === 'object') { + if ('red' in color) { + const [red, green, blue] = hex.rgb(raw); + + return RGBSchema().parse({ + red, + green, + blue + }) as T; + } else if ('hue' in color && 'luminance' in color) { + const [hue, saturation, luminance] = hex.hsl(raw); + + return HSLSchema().parse({ + hue, + saturation: saturation / 100, + luminance: luminance / 100 + }) as T; + } else { + const [hue, saturation, value] = hex.hsv(raw); + + return HSVSchema().parse({ + hue, + saturation: saturation / 100, + value: value / 100 + }) as T; + } + } else { + return HexSchema().parse(raw) as T; + } +}; diff --git a/apps/web/src/windows/GradientWindow.tsx b/apps/web/src/windows/GradientWindow.tsx new file mode 100644 index 0000000..fef824f --- /dev/null +++ b/apps/web/src/windows/GradientWindow.tsx @@ -0,0 +1,58 @@ +import { useEffect, useState } from 'react'; +import { hsv2rgb } from '../components/ColorPicker/ColorPicker.utils'; +import { NodeWindow } from '../circuit/components/Node/Node'; +import { FromHSV } from '../../../../packages/nodes/src/color/FromHSV/FromHSV'; +import { ColorSchema, GradientSchema } from '@bitspace/schemas'; +import { Gradient } from '@bitspace/nodes'; + +const defaultGradient: Zod.infer> = { + type: 'linear', + colors: [ + { color: { hue: 0, saturation: 0, value: 0 }, position: 0 }, + { color: { hue: 0, saturation: 0, value: 0 }, position: 1 } + ], + angle: 0 +}; + +export const GradientWindow = ({ node }: { node: Gradient }) => { + const [color, setColor] = + useState>>(defaultGradient); + + useEffect(() => { + const subscription = node.outputs.output.subscribe(value => { + setColor(value as Zod.infer>); + }); + + return () => { + subscription.unsubscribe(); + }; + }, [node]); + + const gradient = [ + 'linear-gradient(', + `${color.angle}deg, `, + `${color.colors + .map(({ color }) => { + const [r, g, b] = hsv2rgb( + color.hue, + color.saturation, + color.value + ); + + return `rgb(${r}, ${g}, ${b})`; + }) + .join(', ')}`, + ')' + ].join(''); + + return ( + +
+ + ); +}; diff --git a/apps/web/src/windows/HSVWindow.tsx b/apps/web/src/windows/HSVWindow.tsx index 9744b0b..b44f870 100644 --- a/apps/web/src/windows/HSVWindow.tsx +++ b/apps/web/src/windows/HSVWindow.tsx @@ -7,10 +7,12 @@ export const HSVWindow = ({ node }: { node: FromHSV }) => { const [rgb, setRgb] = useState<[number, number, number]>([0, 0, 0]); useEffect(() => { - node.inputs.color.subscribe(hsv => { + const subscription = node.inputs.color.subscribe(hsv => { const rgb = hsv2rgb(hsv.hue, hsv.saturation, hsv.value); setRgb(rgb); }); + + return () => subscription.unsubscribe(); }, [node]); const [r, g, b] = rgb; diff --git a/apps/web/src/windows/index.tsx b/apps/web/src/windows/index.tsx index 22a0e13..332f604 100644 --- a/apps/web/src/windows/index.tsx +++ b/apps/web/src/windows/index.tsx @@ -18,10 +18,11 @@ import { MeshWindow } from './MeshWindow'; import { Mesh } from '../../../../packages/nodes/src/3d/Mesh/Mesh'; import { ImageEditWindow } from './ImageEditWindow'; import { ImageEdit } from '../../../../packages/nodes/src/ai/ImageEdit/ImageEdit'; -import { Image, Oscillator } from '@bitspace/nodes'; +import { Gradient, Image, Oscillator } from '@bitspace/nodes'; import { Webcam } from '@bitspace/nodes'; import { WebcamWindow } from './WebcamWindow'; import { OscillatorWindow } from './OscillatorWindow'; +import { GradientWindow } from './GradientWindow'; export const nodeWindowResolver: NodeWindowResolver = (node: Node) => { if (!('displayName' in node.constructor)) return <>; @@ -62,5 +63,7 @@ export const nodeWindowResolver: NodeWindowResolver = (node: Node) => { ); case 'From HSV': return ; + case 'Gradient': + return ; } }; diff --git a/packages/nodes/src/color/Gradient/Gradient.ts b/packages/nodes/src/color/Gradient/Gradient.ts new file mode 100644 index 0000000..f97400f --- /dev/null +++ b/packages/nodes/src/color/Gradient/Gradient.ts @@ -0,0 +1,56 @@ +import { NodeType } from '../../types'; +import { Input, Node, Output } from '@bitspace/circuit'; +import { ColorSchema, GradientSchema, minMaxNumber } from '@bitspace/schemas'; +import { combineLatest, map } from 'rxjs'; + +export class Gradient extends Node { + static displayName = 'Gradient'; + static type = NodeType.GRADIENT; + + inputs = { + a: new Input({ + name: 'A', + type: ColorSchema(), + defaultValue: { + red: 0, + green: 0, + blue: 0 + } + }), + b: new Input({ + name: 'B', + type: ColorSchema(), + defaultValue: { + red: 0, + green: 0, + blue: 0 + } + }), + angle: new Input({ + name: 'Angle', + type: minMaxNumber(0, 360, true), + defaultValue: 0 + }) + }; + + outputs = { + output: new Output({ + name: 'Output', + type: GradientSchema(), + observable: combineLatest([ + this.inputs.a, + this.inputs.b, + this.inputs.angle + ]).pipe( + map(([a, b, angle]) => ({ + type: 'linear', + colors: [ + { color: a, position: 0 }, + { color: b, position: 1 } + ], + angle + })) + ) + }) + }; +} diff --git a/packages/nodes/src/color/index.ts b/packages/nodes/src/color/index.ts index 63f6a45..b9aa9b5 100644 --- a/packages/nodes/src/color/index.ts +++ b/packages/nodes/src/color/index.ts @@ -3,6 +3,7 @@ import { AnalogousHarmony } from './AnalogousHarmony/AnalogousHarmony'; import { ComplementaryHarmony } from './ComplementaryHarmony/ComplementaryHarmony'; import { FromHSV } from './FromHSV/FromHSV'; import { FromRGB } from './FromRGB/FromRGB'; +import { Gradient } from './Gradient/Gradient'; import { SquareHarmony } from './SquareHarmony/SquareHarmony'; import { TetradicHarmony } from './TetradicHarmony/TetradicHarmony'; import { ToHSV } from './ToHSV/ToHSV'; @@ -16,7 +17,8 @@ export const ColorNodes = [ ComplementaryHarmony, FromHSV, ToHSV, - FromRGB + FromRGB, + Gradient ].sort((a, b) => a.displayName.localeCompare(b.displayName)); export type ColorNode = @@ -27,7 +29,8 @@ export type ColorNode = | ComplementaryHarmony | FromHSV | ToHSV - | FromRGB; + | FromRGB + | Gradient; export interface ColorNodeConstructor { new (): ColorNode; @@ -42,5 +45,6 @@ export { ComplementaryHarmony, FromHSV, ToHSV, - FromRGB + FromRGB, + Gradient }; diff --git a/packages/nodes/src/descriptions.ts b/packages/nodes/src/descriptions.ts index 4061992..902c78a 100644 --- a/packages/nodes/src/descriptions.ts +++ b/packages/nodes/src/descriptions.ts @@ -64,6 +64,7 @@ export const NodeDescriptionsMap: Record = { [NodeType.TO_RGB]: 'Returns a RGB color from a given red, green & blue', [NodeType.TO_HSV]: 'Returns a HSV color from a given hue, saturation & value', + [NodeType.GRADIENT]: 'Returns a gradient based on the input colors', [NodeType.IMAGE]: 'Returns an image from the given source URL', [NodeType.WEBCAM]: 'Returns a webcam stream', diff --git a/packages/nodes/src/types.ts b/packages/nodes/src/types.ts index 0e9b8ac..291bc4c 100644 --- a/packages/nodes/src/types.ts +++ b/packages/nodes/src/types.ts @@ -36,6 +36,7 @@ export enum NodeType { FROM_HSL = 'FROM_HSL', TO_RGB = 'TO_RGB', FROM_RGB = 'FROM_RGB', + GRADIENT = 'GRADIENT', IMAGE = 'IMAGE', WEBCAM = 'WEBCAM', IMAGE_VARIATION_AI = 'IMAGE_VARIATION_AI', diff --git a/packages/schemas/src/color.ts b/packages/schemas/src/color.ts new file mode 100644 index 0000000..5f981b6 --- /dev/null +++ b/packages/schemas/src/color.ts @@ -0,0 +1,37 @@ +import { z } from 'zod'; +import { minMaxNumber } from './utility'; + +export const HSVSchema = () => + z + .object({ + hue: minMaxNumber(0, 360, true), + saturation: minMaxNumber(0, 1), + value: minMaxNumber(0, 1) + }) + .describe('HSV'); + +export const HSLSchema = () => + z + .object({ + hue: minMaxNumber(0, 360, true), + saturation: minMaxNumber(0, 1), + luminance: minMaxNumber(0, 1) + }) + .describe('HSL'); + +export const RGBSchema = () => + z + .object({ + red: minMaxNumber(0, 1), + green: minMaxNumber(0, 1), + blue: minMaxNumber(0, 1) + }) + .describe('RGB'); + +export const HexSchema = () => + z.string().startsWith('#').min(4).max(7).describe('Hex'); + +export const ColorSchema = () => + z + .union([HSVSchema(), HSLSchema(), RGBSchema(), HexSchema()]) + .describe('Color'); diff --git a/packages/schemas/src/gradient.ts b/packages/schemas/src/gradient.ts new file mode 100644 index 0000000..01b7692 --- /dev/null +++ b/packages/schemas/src/gradient.ts @@ -0,0 +1,18 @@ +import { z } from 'zod'; +import { ColorSchema, minMaxNumber } from '.'; + +const GradientType = z.enum(['linear', 'radial', 'conic']); + +const ColorStop = z.object({ + color: ColorSchema(), + position: minMaxNumber(0, 1) +}); + +export const GradientSchema = () => + z + .object({ + type: GradientType, + colors: z.array(ColorStop).min(2), + angle: minMaxNumber(0, 360, true).default(0) + }) + .describe('Gradient'); diff --git a/packages/schemas/src/index.ts b/packages/schemas/src/index.ts index 225ad2b..f25f880 100644 --- a/packages/schemas/src/index.ts +++ b/packages/schemas/src/index.ts @@ -1,21 +1,6 @@ import { z } from 'zod'; import { Mesh } from 'three'; - -/** UTILITY SCHEMAS */ - -export const minMaxNumber = ( - min: number = -Infinity, - max: number = Infinity, - wrapAround?: boolean -) => - z.coerce - .number() - .transform(value => (wrapAround && max ? value % max : value)) - .transform(value => Math.min(Math.max(value, min), max)) - .pipe(z.number().min(min).max(max)) - .describe('Number'); - -/** PUBLIC SCHEMAS */ +import { minMaxNumber } from './utility'; export const AnySchema = () => z.any().describe('Any'); export const BooleanSchema = () => z.coerce.boolean().describe('Boolean'); @@ -55,39 +40,6 @@ export const EasingSchema = () => export const MeshSchema = () => z.instanceof(Mesh).describe('Mesh'); -/** COLORS */ - -export const HSVSchema = () => - z - .object({ - hue: minMaxNumber(0, 360, true), - saturation: minMaxNumber(0, 1), - value: minMaxNumber(0, 1) - }) - .describe('HSV'); - -export const HSLSchema = () => - z - .object({ - hue: minMaxNumber(0, 360, true), - saturation: minMaxNumber(0, 1), - luminance: minMaxNumber(0, 1) - }) - .describe('HSL'); - -export const RGBSchema = () => - z - .object({ - red: minMaxNumber(0, 1), - green: minMaxNumber(0, 1), - blue: minMaxNumber(0, 1) - }) - .describe('RGB'); - -export const HexSchema = () => - z.string().startsWith('#').min(4).max(7).describe('Hex'); - -export const ColorSchema = () => - z - .union([HSVSchema(), HSLSchema(), RGBSchema(), HexSchema()]) - .describe('Color'); +export * from './utility'; +export * from './color'; +export * from './gradient'; diff --git a/packages/schemas/src/utility.ts b/packages/schemas/src/utility.ts new file mode 100644 index 0000000..945efa2 --- /dev/null +++ b/packages/schemas/src/utility.ts @@ -0,0 +1,13 @@ +import { z } from 'zod'; + +export const minMaxNumber = ( + min: number = -Infinity, + max: number = Infinity, + wrapAround?: boolean +) => + z.coerce + .number() + .transform(value => (wrapAround && max ? value % max : value)) + .transform(value => Math.min(Math.max(value, min), max)) + .pipe(z.number().min(min).max(max)) + .describe('Number');