summaryrefslogtreecommitdiff
path: root/apps/web/src/graphics
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-20 11:31:17 +0200
committerMaze Winther <mazewinther@gmail.com>2026-04-20 11:31:17 +0200
commit3e89d2998588f8e35c524e523ba2ee853f0f6475 (patch)
tree57221f96c1259b3b07e3ddf8c1adefc502b9d8eb /apps/web/src/graphics
parent729d10592f4e802f91c555c309ff3ba4b119f843 (diff)
downloadopencut-3e89d2998588f8e35c524e523ba2ee853f0f6475.tar.gz
refactor: restructure files to their domains, new preview overlay system, and dep graph
Diffstat (limited to 'apps/web/src/graphics')
-rw-r--r--apps/web/src/graphics/components/graphic-tab.tsx238
-rw-r--r--apps/web/src/graphics/definitions/ellipse.ts73
-rw-r--r--apps/web/src/graphics/definitions/index.ts29
-rw-r--r--apps/web/src/graphics/definitions/polygon.ts211
-rw-r--r--apps/web/src/graphics/definitions/rectangle.ts85
-rw-r--r--apps/web/src/graphics/definitions/shared.ts16
-rw-r--r--apps/web/src/graphics/definitions/star.ts109
-rw-r--r--apps/web/src/graphics/index.ts125
-rw-r--r--apps/web/src/graphics/registry.ts10
-rw-r--r--apps/web/src/graphics/stroke.ts102
-rw-r--r--apps/web/src/graphics/types.ts23
11 files changed, 1021 insertions, 0 deletions
diff --git a/apps/web/src/graphics/components/graphic-tab.tsx b/apps/web/src/graphics/components/graphic-tab.tsx
new file mode 100644
index 00000000..60599772
--- /dev/null
+++ b/apps/web/src/graphics/components/graphic-tab.tsx
@@ -0,0 +1,238 @@
+"use client";
+
+import { useRef } from "react";
+import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead";
+import {
+ useKeyframedParamProperty,
+ type KeyframedParamPropertyResult,
+} from "@/components/editor/panels/properties/hooks/use-keyframed-param-property";
+import { resolveGraphicParamsAtTime } from "@/animation";
+import type { ParamDefinition, ParamValues } from "@/params";
+import type { GraphicElement } from "@/timeline";
+import { graphicsRegistry, registerDefaultGraphics } from "@/graphics";
+import { useElementPreview } from "@/timeline/hooks/use-element-preview";
+import { useEditor } from "@/editor/use-editor";
+import {
+ Section,
+ SectionContent,
+ SectionFields,
+ SectionHeader,
+ SectionTitle,
+} from "@/components/section";
+import { PropertyParamField } from "@/components/editor/panels/properties/components/property-param-field";
+import { Button } from "@/components/ui/button";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { MinusSignIcon, PlusSignIcon } from "@hugeicons/core-free-icons";
+import { cn } from "@/utils/ui";
+
+registerDefaultGraphics();
+
+const DEFAULT_STROKE_WIDTH = 2;
+
+export function GraphicTab({
+ element,
+ trackId,
+}: {
+ element: GraphicElement;
+ trackId: string;
+}) {
+ const definition = graphicsRegistry.get(element.definitionId);
+ const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
+ startTime: element.startTime,
+ duration: element.duration,
+ });
+ const { renderElement } = useElementPreview({
+ trackId,
+ elementId: element.id,
+ fallback: element,
+ });
+
+ const liveElement = renderElement as GraphicElement;
+ const resolvedParams = resolveGraphicParamsAtTime({
+ element: liveElement,
+ localTime,
+ });
+
+ const shapeParams = definition.params.filter((p) => p.group !== "stroke");
+ const hasStrokeParams = definition.params.some((p) => p.group === "stroke");
+
+ return (
+ <div className="flex flex-col">
+ <Section collapsible sectionKey={`${element.id}:graphic`}>
+ <SectionHeader>
+ <SectionTitle>{definition.name}</SectionTitle>
+ </SectionHeader>
+ <SectionContent>
+ <SectionFields>
+ {shapeParams.map((param) => (
+ <AnimatedGraphicParamField
+ key={param.key}
+ param={param}
+ trackId={trackId}
+ element={liveElement}
+ localTime={localTime}
+ isPlayheadWithinElementRange={isPlayheadWithinElementRange}
+ resolvedParams={resolvedParams}
+ />
+ ))}
+ </SectionFields>
+ </SectionContent>
+ </Section>
+ {hasStrokeParams && <StrokeSection element={element} trackId={trackId} />}
+ </div>
+ );
+}
+
+function StrokeSection({
+ element,
+ trackId,
+}: {
+ element: GraphicElement;
+ trackId: string;
+}) {
+ const editor = useEditor();
+ const definition = graphicsRegistry.get(element.definitionId);
+ const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
+ startTime: element.startTime,
+ duration: element.duration,
+ });
+ const { renderElement } = useElementPreview({
+ trackId,
+ elementId: element.id,
+ fallback: element,
+ });
+
+ const liveElement = renderElement as GraphicElement;
+ const resolvedParams = resolveGraphicParamsAtTime({
+ element: liveElement,
+ localTime,
+ });
+ const strokeParams = definition.params.filter((p) => p.group === "stroke");
+ const lastStrokeWidth = useRef(DEFAULT_STROKE_WIDTH);
+ const isStrokeEnabled = Number(element.params.strokeWidth ?? 0) > 0;
+
+ const toggleStroke = () => {
+ if (isStrokeEnabled) {
+ lastStrokeWidth.current = Number(
+ element.params.strokeWidth ?? DEFAULT_STROKE_WIDTH,
+ );
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ patch: { params: { ...element.params, strokeWidth: 0 } },
+ },
+ ],
+ });
+ } else {
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ patch: {
+ params: {
+ ...element.params,
+ strokeWidth: lastStrokeWidth.current,
+ },
+ },
+ },
+ ],
+ });
+ }
+ };
+
+ return (
+ <Section
+ collapsible
+ defaultOpen={isStrokeEnabled}
+ sectionKey={`${element.id}:stroke`}
+ >
+ <SectionHeader
+ trailing={
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={(event) => {
+ event.stopPropagation();
+ toggleStroke();
+ }}
+ >
+ <HugeiconsIcon
+ icon={isStrokeEnabled ? MinusSignIcon : PlusSignIcon}
+ strokeWidth={1}
+ />
+ </Button>
+ }
+ >
+ <SectionTitle>Stroke</SectionTitle>
+ </SectionHeader>
+ <SectionContent
+ className={cn(!isStrokeEnabled && "pointer-events-none opacity-50")}
+ >
+ <SectionFields>
+ {strokeParams.map((param) => (
+ <AnimatedGraphicParamField
+ key={param.key}
+ param={param}
+ trackId={trackId}
+ element={liveElement}
+ localTime={localTime}
+ isPlayheadWithinElementRange={isPlayheadWithinElementRange}
+ resolvedParams={resolvedParams}
+ />
+ ))}
+ </SectionFields>
+ </SectionContent>
+ </Section>
+ );
+}
+
+function AnimatedGraphicParamField({
+ param,
+ trackId,
+ element,
+ localTime,
+ isPlayheadWithinElementRange,
+ resolvedParams,
+}: {
+ param: ParamDefinition;
+ trackId: string;
+ element: GraphicElement;
+ localTime: number;
+ isPlayheadWithinElementRange: boolean;
+ resolvedParams: ParamValues;
+}) {
+ const animatedParam: KeyframedParamPropertyResult = useKeyframedParamProperty(
+ {
+ param,
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ localTime,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedParams[param.key] ?? param.default,
+ buildBaseUpdates: ({ value }) => ({
+ params: {
+ ...element.params,
+ [param.key]: value,
+ },
+ }),
+ },
+ );
+
+ return (
+ <PropertyParamField
+ param={param}
+ value={resolvedParams[param.key] ?? param.default}
+ onPreview={animatedParam.onPreview}
+ onCommit={animatedParam.onCommit}
+ keyframe={{
+ isActive: animatedParam.isKeyframedAtTime,
+ isDisabled: !isPlayheadWithinElementRange,
+ onToggle: animatedParam.toggleKeyframe,
+ }}
+ />
+ );
+}
diff --git a/apps/web/src/graphics/definitions/ellipse.ts b/apps/web/src/graphics/definitions/ellipse.ts
new file mode 100644
index 00000000..7ae9e8e3
--- /dev/null
+++ b/apps/web/src/graphics/definitions/ellipse.ts
@@ -0,0 +1,73 @@
+import type { ParamDefinition } from "@/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/graphics/definitions/index.ts b/apps/web/src/graphics/definitions/index.ts
new file mode 100644
index 00000000..6fd9e446
--- /dev/null
+++ b/apps/web/src/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/graphics/definitions/polygon.ts b/apps/web/src/graphics/definitions/polygon.ts
new file mode 100644
index 00000000..11273fed
--- /dev/null
+++ b/apps/web/src/graphics/definitions/polygon.ts
@@ -0,0 +1,211 @@
+import type { ParamDefinition } from "@/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/graphics/definitions/rectangle.ts b/apps/web/src/graphics/definitions/rectangle.ts
new file mode 100644
index 00000000..3cd0a449
--- /dev/null
+++ b/apps/web/src/graphics/definitions/rectangle.ts
@@ -0,0 +1,85 @@
+import type { ParamDefinition } from "@/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/graphics/definitions/shared.ts b/apps/web/src/graphics/definitions/shared.ts
new file mode 100644
index 00000000..2bd92875
--- /dev/null
+++ b/apps/web/src/graphics/definitions/shared.ts
@@ -0,0 +1,16 @@
+import type { ParamDefinition } from "@/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/graphics/definitions/star.ts b/apps/web/src/graphics/definitions/star.ts
new file mode 100644
index 00000000..3aa40fee
--- /dev/null
+++ b/apps/web/src/graphics/definitions/star.ts
@@ -0,0 +1,109 @@
+import type { ParamDefinition } from "@/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/graphics/index.ts b/apps/web/src/graphics/index.ts
new file mode 100644
index 00000000..c69a4ab0
--- /dev/null
+++ b/apps/web/src/graphics/index.ts
@@ -0,0 +1,125 @@
+import { buildDefaultParamValues } from "@/params/registry";
+import type { ParamValues } from "@/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/graphics/registry.ts b/apps/web/src/graphics/registry.ts
new file mode 100644
index 00000000..ea40a43a
--- /dev/null
+++ b/apps/web/src/graphics/registry.ts
@@ -0,0 +1,10 @@
+import { DefinitionRegistry } from "@/params/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/graphics/stroke.ts b/apps/web/src/graphics/stroke.ts
new file mode 100644
index 00000000..230d9904
--- /dev/null
+++ b/apps/web/src/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/graphics/types.ts b/apps/web/src/graphics/types.ts
new file mode 100644
index 00000000..1c6cc10e
--- /dev/null
+++ b/apps/web/src/graphics/types.ts
@@ -0,0 +1,23 @@
+import type { ParamDefinition, ParamValues } from "@/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;
+}