summaryrefslogtreecommitdiff
path: root/apps/web/src/lib/graphics
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-03-25 16:47:22 +0100
committerMaze Winther <mazewinther@gmail.com>2026-03-29 15:48:22 +0200
commit8db3bead13441a5882d8689dfc6a9f09c82375d9 (patch)
treee9d0cafa93904ffcbec3188c37961bcc16c6422d /apps/web/src/lib/graphics
parent39ea298a9ccba3104c549c0ca079c231981811f5 (diff)
downloadopencut-8db3bead13441a5882d8689dfc6a9f09c82375d9.tar.gz
feat: masks, properties refactor, shaders, storage migrations, and more
Diffstat (limited to 'apps/web/src/lib/graphics')
-rw-r--r--apps/web/src/lib/graphics/definitions/ellipse.ts73
-rw-r--r--apps/web/src/lib/graphics/definitions/index.ts29
-rw-r--r--apps/web/src/lib/graphics/definitions/polygon.ts211
-rw-r--r--apps/web/src/lib/graphics/definitions/rectangle.ts85
-rw-r--r--apps/web/src/lib/graphics/definitions/shared.ts16
-rw-r--r--apps/web/src/lib/graphics/definitions/star.ts109
-rw-r--r--apps/web/src/lib/graphics/index.ts125
-rw-r--r--apps/web/src/lib/graphics/registry.ts10
-rw-r--r--apps/web/src/lib/graphics/stroke.ts102
-rw-r--r--apps/web/src/lib/graphics/types.ts23
10 files changed, 783 insertions, 0 deletions
diff --git a/apps/web/src/lib/graphics/definitions/ellipse.ts b/apps/web/src/lib/graphics/definitions/ellipse.ts
new file mode 100644
index 00000000..3fd76608
--- /dev/null
+++ b/apps/web/src/lib/graphics/definitions/ellipse.ts
@@ -0,0 +1,73 @@
+import type { ParamDefinition } from "@/lib/params";
+import { applyAlignedStroke } from "../stroke";
+import { STROKE_ALIGN_PARAM, type GraphicStrokeAlign } from "./shared";
+import type { GraphicDefinition } from "../types";
+
+interface EllipseParams {
+ fill: string;
+ stroke: string;
+ strokeWidth: number;
+ strokeAlign: GraphicStrokeAlign;
+}
+
+const ELLIPSE_PARAMS: ParamDefinition<keyof EllipseParams & string>[] = [
+ {
+ key: "fill",
+ label: "Fill",
+ type: "color",
+ default: "#ffffff",
+ },
+ {
+ key: "stroke",
+ label: "Color",
+ type: "color",
+ default: "#000000",
+ group: "stroke",
+ },
+ {
+ key: "strokeWidth",
+ label: "Width",
+ type: "number",
+ default: 0,
+ min: 0,
+ max: 64,
+ step: 1,
+ shortLabel: "W",
+ group: "stroke",
+ },
+ STROKE_ALIGN_PARAM,
+];
+
+export const ellipseGraphicDefinition: GraphicDefinition = {
+ id: "ellipse",
+ name: "Ellipse",
+ keywords: ["ellipse", "circle", "oval"],
+ params: ELLIPSE_PARAMS,
+ render({ ctx, params, width, height }) {
+ const fill = String(params.fill ?? "#ffffff");
+ const stroke = String(params.stroke ?? "#000000");
+ const strokeWidth = Math.max(0, Number(params.strokeWidth ?? 0));
+ const strokeAlign = (params.strokeAlign ?? "center") as GraphicStrokeAlign;
+ const inset = strokeAlign === "center" ? strokeWidth / 2 : 0;
+ const centerX = width / 2;
+ const centerY = height / 2;
+ const radiusX = Math.max(1, width / 2 - inset);
+ const radiusY = Math.max(1, height / 2 - inset);
+
+ ctx.clearRect(0, 0, width, height);
+ const path = new Path2D();
+ path.ellipse(centerX, centerY, radiusX, radiusY, 0, 0, Math.PI * 2);
+ ctx.fillStyle = fill;
+ ctx.fill(path);
+
+ if (strokeWidth > 0) {
+ applyAlignedStroke({
+ ctx,
+ path,
+ strokeWidth,
+ strokeAlign,
+ strokeColor: stroke,
+ });
+ }
+ },
+};
diff --git a/apps/web/src/lib/graphics/definitions/index.ts b/apps/web/src/lib/graphics/definitions/index.ts
new file mode 100644
index 00000000..e791f917
--- /dev/null
+++ b/apps/web/src/lib/graphics/definitions/index.ts
@@ -0,0 +1,29 @@
+import { graphicsRegistry } from "../registry";
+import { ellipseGraphicDefinition } from "./ellipse";
+import { polygonGraphicDefinition } from "./polygon";
+import { rectangleGraphicDefinition } from "./rectangle";
+import { starGraphicDefinition } from "./star";
+
+const defaultGraphicDefinitions = [
+ rectangleGraphicDefinition,
+ ellipseGraphicDefinition,
+ polygonGraphicDefinition,
+ starGraphicDefinition,
+];
+
+export function registerDefaultGraphics(): void {
+ for (const definition of defaultGraphicDefinitions) {
+ if (graphicsRegistry.has(definition.id)) {
+ continue;
+ }
+ graphicsRegistry.register(definition.id, definition);
+ }
+}
+
+export {
+ ellipseGraphicDefinition,
+ polygonGraphicDefinition,
+ rectangleGraphicDefinition,
+ starGraphicDefinition,
+};
+export { STROKE_ALIGN_PARAM } from "./shared";
diff --git a/apps/web/src/lib/graphics/definitions/polygon.ts b/apps/web/src/lib/graphics/definitions/polygon.ts
new file mode 100644
index 00000000..780c974c
--- /dev/null
+++ b/apps/web/src/lib/graphics/definitions/polygon.ts
@@ -0,0 +1,211 @@
+import type { ParamDefinition } from "@/lib/params";
+import { applyAlignedStroke } from "../stroke";
+import { STROKE_ALIGN_PARAM, type GraphicStrokeAlign } from "./shared";
+import type { GraphicDefinition } from "../types";
+
+interface Point {
+ x: number;
+ y: number;
+}
+
+interface PolygonParams {
+ fill: string;
+ stroke: string;
+ strokeWidth: number;
+ strokeAlign: GraphicStrokeAlign;
+ sides: number;
+ cornerRadius: number;
+}
+
+const POLYGON_PARAMS: ParamDefinition<keyof PolygonParams & string>[] = [
+ {
+ key: "fill",
+ label: "Fill",
+ type: "color",
+ default: "#ffffff",
+ },
+ {
+ key: "stroke",
+ label: "Color",
+ type: "color",
+ default: "#000000",
+ group: "stroke",
+ },
+ {
+ key: "strokeWidth",
+ label: "Width",
+ type: "number",
+ default: 0,
+ min: 0,
+ max: 64,
+ step: 1,
+ shortLabel: "W",
+ group: "stroke",
+ },
+ STROKE_ALIGN_PARAM,
+ {
+ key: "sides",
+ label: "Sides",
+ type: "number",
+ default: 5,
+ min: 3,
+ max: 12,
+ step: 1,
+ shortLabel: "S",
+ },
+ {
+ key: "cornerRadius",
+ label: "Corner radius",
+ type: "number",
+ default: 0,
+ min: 0,
+ max: 50,
+ step: 1,
+ shortLabel: "R",
+ },
+];
+
+function buildPolygonVertices({
+ centerX,
+ centerY,
+ radius,
+ sides,
+}: {
+ centerX: number;
+ centerY: number;
+ radius: number;
+ sides: number;
+}): Point[] {
+ return Array.from({ length: sides }, (_, index) => {
+ const angle = -Math.PI / 2 + (index * Math.PI * 2) / sides;
+ return {
+ x: centerX + Math.cos(angle) * radius,
+ y: centerY + Math.sin(angle) * radius,
+ };
+ });
+}
+
+function distance({ a, b }: { a: Point; b: Point }): number {
+ return Math.hypot(a.x - b.x, a.y - b.y);
+}
+
+function normalize(point: Point): Point {
+ const length = Math.hypot(point.x, point.y) || 1;
+ return {
+ x: point.x / length,
+ y: point.y / length,
+ };
+}
+
+function traceRoundedPolygonPath({
+ path,
+ vertices,
+ radius,
+}: {
+ path: Path2D;
+ vertices: Point[];
+ radius: number;
+}): void {
+ if (vertices.length < 3) {
+ return;
+ }
+
+ if (radius <= 0) {
+ path.moveTo(vertices[0].x, vertices[0].y);
+ for (let index = 1; index < vertices.length; index++) {
+ path.lineTo(vertices[index].x, vertices[index].y);
+ }
+ path.closePath();
+ return;
+ }
+
+ for (let index = 0; index < vertices.length; index++) {
+ const previous = vertices[(index - 1 + vertices.length) % vertices.length];
+ const current = vertices[index];
+ const next = vertices[(index + 1) % vertices.length];
+ const toPrevious = normalize({
+ x: previous.x - current.x,
+ y: previous.y - current.y,
+ });
+ const toNext = normalize({
+ x: next.x - current.x,
+ y: next.y - current.y,
+ });
+ const angle = Math.acos(
+ Math.max(-1, Math.min(1, toPrevious.x * toNext.x + toPrevious.y * toNext.y)),
+ );
+ const maxOffset =
+ Math.min(distance({ a: previous, b: current }), distance({ a: current, b: next })) / 2;
+ const tangentOffset = Math.min(radius / Math.tan(angle / 2), maxOffset);
+ const start = {
+ x: current.x + toPrevious.x * tangentOffset,
+ y: current.y + toPrevious.y * tangentOffset,
+ };
+ const end = {
+ x: current.x + toNext.x * tangentOffset,
+ y: current.y + toNext.y * tangentOffset,
+ };
+
+ if (index === 0) {
+ path.moveTo(start.x, start.y);
+ } else {
+ path.lineTo(start.x, start.y);
+ }
+
+ path.arcTo(
+ current.x,
+ current.y,
+ end.x,
+ end.y,
+ Math.min(radius, maxOffset),
+ );
+ }
+
+ path.closePath();
+}
+
+export const polygonGraphicDefinition: GraphicDefinition = {
+ id: "polygon",
+ name: "Polygon",
+ keywords: ["polygon", "triangle", "pentagon", "hexagon", "diamond"],
+ params: POLYGON_PARAMS,
+ render({ ctx, params, width, height }) {
+ const fill = String(params.fill ?? "#ffffff");
+ const stroke = String(params.stroke ?? "#000000");
+ const strokeWidth = Math.max(0, Number(params.strokeWidth ?? 0));
+ const strokeAlign = (params.strokeAlign ?? "center") as GraphicStrokeAlign;
+ const sides = Math.max(3, Math.min(12, Math.round(Number(params.sides ?? 5))));
+ const inset = strokeAlign === "center" ? strokeWidth / 2 : 0;
+ const radius = Math.max(1, Math.min(width, height) / 2 - inset);
+ const maxCornerRadius = radius * Math.sin(Math.PI / sides);
+ const cornerRadiusPercent = Math.max(0, Number(params.cornerRadius ?? 0));
+ const cornerRadius =
+ maxCornerRadius * Math.min(cornerRadiusPercent, 50) / 50;
+ const vertices = buildPolygonVertices({
+ centerX: width / 2,
+ centerY: height / 2,
+ radius,
+ sides,
+ });
+
+ ctx.clearRect(0, 0, width, height);
+ const path = new Path2D();
+ traceRoundedPolygonPath({
+ path,
+ vertices,
+ radius: cornerRadius,
+ });
+ ctx.fillStyle = fill;
+ ctx.fill(path);
+
+ if (strokeWidth > 0) {
+ applyAlignedStroke({
+ ctx,
+ path,
+ strokeWidth,
+ strokeAlign,
+ strokeColor: stroke,
+ });
+ }
+ },
+};
diff --git a/apps/web/src/lib/graphics/definitions/rectangle.ts b/apps/web/src/lib/graphics/definitions/rectangle.ts
new file mode 100644
index 00000000..4e7f78f6
--- /dev/null
+++ b/apps/web/src/lib/graphics/definitions/rectangle.ts
@@ -0,0 +1,85 @@
+import type { ParamDefinition } from "@/lib/params";
+import { applyAlignedStroke } from "../stroke";
+import { STROKE_ALIGN_PARAM, type GraphicStrokeAlign } from "./shared";
+import type { GraphicDefinition } from "../types";
+
+interface RectangleParams {
+ fill: string;
+ stroke: string;
+ strokeWidth: number;
+ strokeAlign: GraphicStrokeAlign;
+ cornerRadius: number;
+}
+
+const RECTANGLE_PARAMS: ParamDefinition<keyof RectangleParams & string>[] = [
+ {
+ key: "fill",
+ label: "Fill",
+ type: "color",
+ default: "#ffffff",
+ },
+ {
+ key: "stroke",
+ label: "Color",
+ type: "color",
+ default: "#000000",
+ group: "stroke",
+ },
+ {
+ key: "strokeWidth",
+ label: "Width",
+ type: "number",
+ default: 0,
+ min: 0,
+ max: 64,
+ step: 1,
+ shortLabel: "W",
+ group: "stroke",
+ },
+ STROKE_ALIGN_PARAM,
+ {
+ key: "cornerRadius",
+ label: "Corner radius",
+ type: "number",
+ default: 0,
+ min: 0,
+ max: 50,
+ step: 1,
+ shortLabel: "R",
+ },
+];
+
+export const rectangleGraphicDefinition: GraphicDefinition = {
+ id: "rectangle",
+ name: "Rectangle",
+ keywords: ["rectangle", "square", "box"],
+ params: RECTANGLE_PARAMS,
+ render({ ctx, params, width, height }) {
+ const fill = String(params.fill ?? "#ffffff");
+ const stroke = String(params.stroke ?? "#000000");
+ const strokeWidth = Math.max(0, Number(params.strokeWidth ?? 0));
+ const strokeAlign = (params.strokeAlign ?? "center") as GraphicStrokeAlign;
+ const inset = strokeAlign === "center" ? strokeWidth / 2 : 0;
+ const drawWidth = Math.max(1, width - inset * 2);
+ const drawHeight = Math.max(1, height - inset * 2);
+ const radiusPercent = Math.max(0, Number(params.cornerRadius ?? 0));
+ const radius =
+ (Math.min(drawWidth, drawHeight) / 2) * Math.min(radiusPercent, 50) / 50;
+
+ ctx.clearRect(0, 0, width, height);
+ const path = new Path2D();
+ path.roundRect(inset, inset, drawWidth, drawHeight, radius);
+ ctx.fillStyle = fill;
+ ctx.fill(path);
+
+ if (strokeWidth > 0) {
+ applyAlignedStroke({
+ ctx,
+ path,
+ strokeWidth,
+ strokeAlign,
+ strokeColor: stroke,
+ });
+ }
+ },
+};
diff --git a/apps/web/src/lib/graphics/definitions/shared.ts b/apps/web/src/lib/graphics/definitions/shared.ts
new file mode 100644
index 00000000..d2833f51
--- /dev/null
+++ b/apps/web/src/lib/graphics/definitions/shared.ts
@@ -0,0 +1,16 @@
+import type { ParamDefinition } from "@/lib/params";
+
+export type GraphicStrokeAlign = "inside" | "center" | "outside";
+
+export const STROKE_ALIGN_PARAM: ParamDefinition<"strokeAlign"> = {
+ key: "strokeAlign",
+ label: "Stroke align",
+ type: "select",
+ default: "center",
+ group: "stroke",
+ options: [
+ { value: "inside", label: "Inside" },
+ { value: "center", label: "Center" },
+ { value: "outside", label: "Outside" },
+ ],
+};
diff --git a/apps/web/src/lib/graphics/definitions/star.ts b/apps/web/src/lib/graphics/definitions/star.ts
new file mode 100644
index 00000000..c7f9ef56
--- /dev/null
+++ b/apps/web/src/lib/graphics/definitions/star.ts
@@ -0,0 +1,109 @@
+import type { ParamDefinition } from "@/lib/params";
+import { applyAlignedStroke } from "../stroke";
+import { STROKE_ALIGN_PARAM, type GraphicStrokeAlign } from "./shared";
+import type { GraphicDefinition } from "../types";
+
+interface StarParams {
+ fill: string;
+ stroke: string;
+ strokeWidth: number;
+ strokeAlign: GraphicStrokeAlign;
+ points: number;
+ depth: number;
+}
+
+const STAR_PARAMS: ParamDefinition<keyof StarParams & string>[] = [
+ {
+ key: "fill",
+ label: "Fill",
+ type: "color",
+ default: "#ffffff",
+ },
+ {
+ key: "stroke",
+ label: "Color",
+ type: "color",
+ default: "#000000",
+ group: "stroke",
+ },
+ {
+ key: "strokeWidth",
+ label: "Width",
+ type: "number",
+ default: 0,
+ min: 0,
+ max: 64,
+ step: 1,
+ shortLabel: "W",
+ group: "stroke",
+ },
+ STROKE_ALIGN_PARAM,
+ {
+ key: "points",
+ label: "Points",
+ type: "number",
+ default: 5,
+ min: 3,
+ max: 12,
+ step: 1,
+ shortLabel: "P",
+ },
+ {
+ key: "depth",
+ label: "Depth",
+ type: "number",
+ default: 45,
+ min: 1,
+ max: 99,
+ step: 1,
+ shortLabel: "D",
+ },
+];
+
+export const starGraphicDefinition: GraphicDefinition = {
+ id: "star",
+ name: "Star",
+ keywords: ["star", "sparkle", "burst"],
+ params: STAR_PARAMS,
+ render({ ctx, params, width, height }) {
+ const fill = String(params.fill ?? "#ffffff");
+ const stroke = String(params.stroke ?? "#000000");
+ const strokeWidth = Math.max(0, Number(params.strokeWidth ?? 0));
+ const strokeAlign = (params.strokeAlign ?? "center") as GraphicStrokeAlign;
+ const points = Math.max(3, Math.min(12, Math.round(Number(params.points ?? 5))));
+ const depth = Math.max(1, Math.min(99, Number(params.depth ?? 45))) / 100;
+ const inset = strokeAlign === "center" ? strokeWidth / 2 : 0;
+ const outerRadius = Math.max(1, Math.min(width, height) / 2 - inset);
+ const innerRadius = outerRadius * depth;
+ const centerX = width / 2;
+ const centerY = height / 2;
+
+ ctx.clearRect(0, 0, width, height);
+ const path = new Path2D();
+ for (let index = 0; index < points * 2; index++) {
+ const radius = index % 2 === 0 ? outerRadius : innerRadius;
+ const angle = -Math.PI / 2 + (index * Math.PI) / points;
+ const x = centerX + Math.cos(angle) * radius;
+ const y = centerY + Math.sin(angle) * radius;
+
+ if (index === 0) {
+ path.moveTo(x, y);
+ } else {
+ path.lineTo(x, y);
+ }
+ }
+ path.closePath();
+ ctx.fillStyle = fill;
+ ctx.fill(path);
+
+ if (strokeWidth > 0) {
+ applyAlignedStroke({
+ ctx,
+ path,
+ strokeWidth,
+ strokeAlign,
+ strokeColor: stroke,
+ });
+ }
+ },
+};
diff --git a/apps/web/src/lib/graphics/index.ts b/apps/web/src/lib/graphics/index.ts
new file mode 100644
index 00000000..5286e932
--- /dev/null
+++ b/apps/web/src/lib/graphics/index.ts
@@ -0,0 +1,125 @@
+import { buildDefaultParamValues } from "@/lib/registry";
+import type { ParamValues } from "@/lib/params";
+import { graphicsRegistry } from "./registry";
+import {
+ registerDefaultGraphics,
+ ellipseGraphicDefinition,
+ polygonGraphicDefinition,
+ rectangleGraphicDefinition,
+ starGraphicDefinition,
+} from "./definitions";
+import {
+ DEFAULT_GRAPHIC_SOURCE_SIZE,
+ type GraphicInstance,
+ type GraphicDefinition,
+} from "./types";
+
+const graphicPreviewUrlCache = new Map<string, string>();
+
+const FALLBACK_CORNER_RADIUS_RATIO = 0.2;
+const FALLBACK_FILL_OPACITY = 0.08;
+const FALLBACK_MIN_FONT_SIZE = 12;
+const FALLBACK_FONT_SIZE_RATIO = 0.15;
+
+function buildFallbackPreviewUrl({
+ name,
+ size,
+}: {
+ name: string;
+ size: number;
+}): string {
+ const svg = `
+ <svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
+ <rect width="${size}" height="${size}" rx="${size * FALLBACK_CORNER_RADIUS_RATIO}" fill="white" fill-opacity="${FALLBACK_FILL_OPACITY}" />
+ <text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" fill="white" font-size="${Math.max(FALLBACK_MIN_FONT_SIZE, size * FALLBACK_FONT_SIZE_RATIO)}" font-family="sans-serif">${name}</text>
+ </svg>
+ `;
+ return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
+}
+
+export function getGraphicDefinition({
+ definitionId,
+}: {
+ definitionId: string;
+}): GraphicDefinition {
+ registerDefaultGraphics();
+ return graphicsRegistry.get(definitionId);
+}
+
+export function buildDefaultGraphicInstance({
+ definitionId,
+}: {
+ definitionId: string;
+}): GraphicInstance {
+ const definition = getGraphicDefinition({ definitionId });
+ return {
+ definitionId,
+ params: buildDefaultParamValues(definition.params),
+ };
+}
+
+export function resolveGraphicParams(
+ definition: GraphicDefinition,
+ params?: ParamValues,
+): ParamValues {
+ return {
+ ...buildDefaultParamValues(definition.params),
+ ...(params ?? {}),
+ };
+}
+
+export function buildGraphicPreviewUrl({
+ definitionId,
+ params,
+ size = DEFAULT_GRAPHIC_SOURCE_SIZE,
+}: {
+ definitionId: string;
+ params?: ParamValues;
+ size?: number;
+}): string {
+ const definition = getGraphicDefinition({ definitionId });
+ const resolvedParams = resolveGraphicParams(definition, params);
+ const cacheKey = JSON.stringify({ definitionId, resolvedParams, size });
+ const cachedUrl = graphicPreviewUrlCache.get(cacheKey);
+ if (cachedUrl) {
+ return cachedUrl;
+ }
+
+ if (typeof document === "undefined") {
+ return buildFallbackPreviewUrl({ name: definition.name, size });
+ }
+
+ const canvas = document.createElement("canvas");
+ canvas.width = size;
+ canvas.height = size;
+ const ctx = canvas.getContext("2d");
+ if (!ctx) {
+ return buildFallbackPreviewUrl({ name: definition.name, size });
+ }
+
+ definition.render({
+ ctx,
+ params: resolvedParams,
+ width: size,
+ height: size,
+ });
+
+ const previewUrl = canvas.toDataURL("image/png");
+ graphicPreviewUrlCache.set(cacheKey, previewUrl);
+ return previewUrl;
+}
+
+export {
+ DEFAULT_GRAPHIC_SOURCE_SIZE,
+ ellipseGraphicDefinition,
+ graphicsRegistry,
+ polygonGraphicDefinition,
+ rectangleGraphicDefinition,
+ registerDefaultGraphics,
+ starGraphicDefinition,
+};
+export type {
+ GraphicDefinition,
+ GraphicInstance,
+ GraphicRenderContext,
+} from "./types";
diff --git a/apps/web/src/lib/graphics/registry.ts b/apps/web/src/lib/graphics/registry.ts
new file mode 100644
index 00000000..7f2ab9fd
--- /dev/null
+++ b/apps/web/src/lib/graphics/registry.ts
@@ -0,0 +1,10 @@
+import { DefinitionRegistry } from "@/lib/registry";
+import type { GraphicDefinition } from "./types";
+
+export class GraphicsRegistry extends DefinitionRegistry<string, GraphicDefinition> {
+ constructor() {
+ super("graphic");
+ }
+}
+
+export const graphicsRegistry = new GraphicsRegistry();
diff --git a/apps/web/src/lib/graphics/stroke.ts b/apps/web/src/lib/graphics/stroke.ts
new file mode 100644
index 00000000..98f9f43f
--- /dev/null
+++ b/apps/web/src/lib/graphics/stroke.ts
@@ -0,0 +1,102 @@
+import type { GraphicStrokeAlign } from "./definitions/shared";
+
+type GraphicRenderContext =
+ | CanvasRenderingContext2D
+ | OffscreenCanvasRenderingContext2D;
+
+function createTempCanvas({
+ width,
+ height,
+}: {
+ width: number;
+ height: number;
+}): OffscreenCanvas | HTMLCanvasElement {
+ try {
+ return new OffscreenCanvas(width, height);
+ } catch {
+ const canvas = document.createElement("canvas");
+ canvas.width = width;
+ canvas.height = height;
+ return canvas;
+ }
+}
+
+function applyStroke({
+ ctx,
+ path,
+ strokeWidth,
+ strokeColor,
+}: {
+ ctx: GraphicRenderContext;
+ path: Path2D;
+ strokeWidth: number;
+ strokeColor: string;
+}) {
+ ctx.strokeStyle = strokeColor;
+ ctx.lineWidth = strokeWidth;
+ ctx.stroke(path);
+}
+
+export function applyAlignedStroke({
+ ctx,
+ path,
+ strokeWidth,
+ strokeAlign,
+ strokeColor,
+}: {
+ ctx: GraphicRenderContext;
+ path: Path2D;
+ strokeWidth: number;
+ strokeAlign: GraphicStrokeAlign;
+ strokeColor: string;
+}): void {
+ if (strokeWidth <= 0) {
+ return;
+ }
+
+ if (strokeAlign === "inside") {
+ ctx.save();
+ ctx.clip(path);
+ applyStroke({
+ ctx,
+ path,
+ strokeWidth: strokeWidth * 2,
+ strokeColor,
+ });
+ ctx.restore();
+ return;
+ }
+
+ if (strokeAlign === "outside") {
+ const strokeCanvas = createTempCanvas({
+ width: ctx.canvas.width,
+ height: ctx.canvas.height,
+ });
+ const strokeCtx = strokeCanvas.getContext("2d") as GraphicRenderContext | null;
+ if (!strokeCtx) {
+ return;
+ }
+
+ applyStroke({
+ ctx: strokeCtx,
+ path,
+ strokeWidth: strokeWidth * 2,
+ strokeColor,
+ });
+
+ // Keep only the outer half of the doubled stroke so alpha fills do not
+ // leave a visible inner stroke behind.
+ strokeCtx.globalCompositeOperation = "destination-out";
+ strokeCtx.fill(path);
+
+ ctx.drawImage(strokeCanvas, 0, 0);
+ return;
+ }
+
+ applyStroke({
+ ctx,
+ path,
+ strokeWidth,
+ strokeColor,
+ });
+}
diff --git a/apps/web/src/lib/graphics/types.ts b/apps/web/src/lib/graphics/types.ts
new file mode 100644
index 00000000..af932d39
--- /dev/null
+++ b/apps/web/src/lib/graphics/types.ts
@@ -0,0 +1,23 @@
+import type { ParamDefinition, ParamValues } from "@/lib/params";
+
+export const DEFAULT_GRAPHIC_SOURCE_SIZE = 512;
+
+export interface GraphicRenderContext {
+ ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
+ params: ParamValues;
+ width: number;
+ height: number;
+}
+
+export interface GraphicDefinition {
+ id: string;
+ name: string;
+ keywords: string[];
+ params: ParamDefinition[];
+ render(context: GraphicRenderContext): void;
+}
+
+export interface GraphicInstance {
+ definitionId: string;
+ params: ParamValues;
+}