/** * @file draw/primitives.ts — Phase 25 geometry-engine primitive layer * @scope apps/studio/draw/primitives.ts * @purpose The `DrawPrimitive` union + grid-snapped constructors + transform * composition + viewBox presets. This is the "intent" layer the * draw-agent emits: the LLM names shapes and where they go; the * constructors here produce exact, structured primitives that the * serializer (`serialize.ts`) turns into BOTH an SVG string and JSX * from the SAME source — so on-disk and on-canvas forms can never * drift. This generalizes `canvas-arrowheads.ts`'s `SvgPrimitive` * reducer pattern to the full drawing vocabulary. * * Back-compat note: this is a superset of the arrow `SvgPrimitive` * (line / path / polyline / polygon / circle) extended with rect, * ellipse, text, group, use, symbol + a shared `DrawStyle`. * * DEPENDENCY RULE (DDR-067): this module imports NOTHING from react * or any `.tsx`. JSX rendering lives ONLY in `serialize.ts#toJsx`, * which produces a JSX *string*, never a React element. A `.ts` * root in a re-export cycle with a react `.tsx` breaks * @types/react's global `JSX` namespace under this tsconfig's * `types: ["bun-types"]` — keep the dependency one-way. */ /** A 2-D point in viewBox user units. */ export interface Point { x: number; y: number; } /** * Presentation attributes shared by every drawable primitive. Geometry lives on * the primitive itself; everything paint-related lives here so a single style * shape feeds every element kind. Names are canonical (camelCase); the * serializer maps them to `stroke-width` (SVG) vs `strokeWidth` (JSX). * * `dash` accepts `true` (a sensible default dash pattern), a number array * (explicit `stroke-dasharray`), or is omitted for a solid stroke — mirroring * the arrow `dash: boolean` field while allowing richer patterns. */ export interface DrawStyle { /** Fill paint. Omit to let the serializer default to `currentColor`. */ fill?: string; /** `'none'` for an unfilled outline shape. */ stroke?: string; strokeWidth?: number; strokeLinecap?: 'butt' | 'round' | 'square'; strokeLinejoin?: 'miter' | 'round' | 'bevel'; /** Element-wide opacity (0–1). */ opacity?: number; fillOpacity?: number; strokeOpacity?: number; /** Solid (omitted/false), default dashes (true), or explicit pattern. */ dash?: boolean | number[]; /** Optional id (for `` targets, testing hooks, a11y). */ id?: string; /** `url(#id)` of a {@link filter} (blur / shadow / grain / glow). */ filter?: string; /** `url(#id)` of a {@link mask} (vignette / scrim / fade overlay). */ mask?: string; /** `url(#id)` of a {@link clipPath}. */ clipPath?: string; /** CSS compositing — `'multiply' | 'screen' | 'overlay' | 'soft-light' | …`. */ mixBlendMode?: string; } /** `gradientUnits` mode. `objectBoundingBox` (default) uses 0–1 coords. */ export type GradientUnits = 'objectBoundingBox' | 'userSpaceOnUse'; /** One color stop of a gradient. `offset` 0–1; optional per-stop opacity. */ export interface GradientStop { offset: number; color: string; opacity?: number; } /** * A single SVG filter-primitive node (`feGaussianBlur`, `feTurbulence`, * `feDropShadow`, `feColorMatrix`, `feMerge` + `feMergeNode`, …). Open-ended by * design — the filter sub-language has dozens of elements, so this is a generic * `{ tag, attrs, children }` rather than an enumerated union. Attr keys are * camelCase (`stdDeviation`, `baseFrequency`, `floodColor`); the serializer * maps the few kebab ones (`flood-color`) per dialect. */ export interface FePrimitive { fe: string; attrs?: Record; children?: FePrimitive[]; } /** Text-specific attributes layered on top of {@link DrawStyle}. */ export interface TextAttrs { fontSize?: number; fontFamily?: string; fontWeight?: number | string; textAnchor?: 'start' | 'middle' | 'end'; dominantBaseline?: 'auto' | 'middle' | 'central' | 'hanging' | 'text-after-edge'; letterSpacing?: number; } /** * The full drawing vocabulary. Every variant carries `el` as its discriminant * (matching the `SvgPrimitive` convention) plus geometry, plus an inlined * {@link DrawStyle}. Containers (`group`, `defs`, `symbol`) nest children. */ export type DrawPrimitive = | ({ el: 'rect'; x: number; y: number; width: number; height: number; rx?: number; ry?: number; } & DrawStyle) | ({ el: 'circle'; cx: number; cy: number; r: number } & DrawStyle) | ({ el: 'ellipse'; cx: number; cy: number; rx: number; ry: number } & DrawStyle) | ({ el: 'line'; x1: number; y1: number; x2: number; y2: number } & DrawStyle) | ({ el: 'polyline'; points: Point[] } & DrawStyle) | ({ el: 'polygon'; points: Point[] } & DrawStyle) | ({ el: 'path'; d: string } & DrawStyle) | ({ el: 'text'; x: number; y: number; content: string } & TextAttrs & DrawStyle) | ({ el: 'group'; children: DrawPrimitive[]; transform?: string } & Pick< DrawStyle, 'opacity' | 'id' | 'filter' | 'mask' | 'clipPath' | 'mixBlendMode' >) | { el: 'defs'; children: DrawPrimitive[] } | { el: 'symbol'; id: string; children: DrawPrimitive[]; viewBox?: string } | ({ el: 'use'; href: string; x?: number; y?: number; width?: number; height?: number; transform?: string; } & DrawStyle) | { el: 'linearGradient'; id: string; stops: GradientStop[]; x1?: number; y1?: number; x2?: number; y2?: number; gradientUnits?: GradientUnits; } | { el: 'radialGradient'; id: string; stops: GradientStop[]; cx?: number; cy?: number; r?: number; fx?: number; fy?: number; gradientUnits?: GradientUnits; } | { el: 'filter'; id: string; prims: FePrimitive[]; x?: string | number; y?: string | number; width?: string | number; height?: string | number; colorInterpolationFilters?: 'sRGB' | 'linearRGB'; } | { el: 'pattern'; id: string; width: number; height: number; children: DrawPrimitive[]; patternUnits?: GradientUnits; patternTransform?: string; } | { el: 'mask'; id: string; children: DrawPrimitive[] } | { el: 'clipPath'; id: string; children: DrawPrimitive[] }; // ───────────────────────────────────────────────────────────────────────────── // Grid snapping // ───────────────────────────────────────────────────────────────────────────── /** * Snap a coordinate to a grid. `grid <= 0` (the default) is a no-op so optical * adjustments survive; pass `grid: 1` for pixel-snapping, `4`/`8` for the * spacing scale. Rounds half-up, deterministic (no `Math.random`). */ export function snap(value: number, grid = 0): number { if (!grid || grid <= 0) return value; return Math.round(value / grid) * grid; } function snapPoint(p: Point, grid: number): Point { return { x: snap(p.x, grid), y: snap(p.y, grid) }; } // ───────────────────────────────────────────────────────────────────────────── // Constructors — each accepts geometry + style + an optional `grid` to snap. // The `grid` field is stripped from the emitted primitive (it's a build-time // instruction, not an SVG attribute). // ───────────────────────────────────────────────────────────────────────────── type WithGrid = T & { grid?: number }; function styleOf(o: T): DrawStyle { const s: DrawStyle = {}; if (o.fill !== undefined) s.fill = o.fill; if (o.stroke !== undefined) s.stroke = o.stroke; if (o.strokeWidth !== undefined) s.strokeWidth = o.strokeWidth; if (o.strokeLinecap !== undefined) s.strokeLinecap = o.strokeLinecap; if (o.strokeLinejoin !== undefined) s.strokeLinejoin = o.strokeLinejoin; if (o.opacity !== undefined) s.opacity = o.opacity; if (o.fillOpacity !== undefined) s.fillOpacity = o.fillOpacity; if (o.strokeOpacity !== undefined) s.strokeOpacity = o.strokeOpacity; if (o.dash !== undefined) s.dash = o.dash; if (o.id !== undefined) s.id = o.id; if (o.filter !== undefined) s.filter = o.filter; if (o.mask !== undefined) s.mask = o.mask; if (o.clipPath !== undefined) s.clipPath = o.clipPath; if (o.mixBlendMode !== undefined) s.mixBlendMode = o.mixBlendMode; return s; } export function rect( o: WithGrid< { x: number; y: number; width: number; height: number; rx?: number; ry?: number } & DrawStyle > ): DrawPrimitive { const g = o.grid ?? 0; const out: DrawPrimitive = { el: 'rect', x: snap(o.x, g), y: snap(o.y, g), width: snap(o.width, g), height: snap(o.height, g), ...styleOf(o), }; if (o.rx !== undefined) (out as { rx?: number }).rx = snap(o.rx, g); if (o.ry !== undefined) (out as { ry?: number }).ry = snap(o.ry, g); return out; } export function circle( o: WithGrid<{ cx: number; cy: number; r: number } & DrawStyle> ): DrawPrimitive { const g = o.grid ?? 0; return { el: 'circle', cx: snap(o.cx, g), cy: snap(o.cy, g), r: snap(o.r, g), ...styleOf(o) }; } export function ellipse( o: WithGrid<{ cx: number; cy: number; rx: number; ry: number } & DrawStyle> ): DrawPrimitive { const g = o.grid ?? 0; return { el: 'ellipse', cx: snap(o.cx, g), cy: snap(o.cy, g), rx: snap(o.rx, g), ry: snap(o.ry, g), ...styleOf(o), }; } export function line( o: WithGrid<{ x1: number; y1: number; x2: number; y2: number } & DrawStyle> ): DrawPrimitive { const g = o.grid ?? 0; return { el: 'line', x1: snap(o.x1, g), y1: snap(o.y1, g), x2: snap(o.x2, g), y2: snap(o.y2, g), ...styleOf(o), }; } export function polyline(o: WithGrid<{ points: Point[] } & DrawStyle>): DrawPrimitive { const g = o.grid ?? 0; return { el: 'polyline', points: o.points.map((p) => snapPoint(p, g)), ...styleOf(o) }; } export function polygon(o: WithGrid<{ points: Point[] } & DrawStyle>): DrawPrimitive { const g = o.grid ?? 0; return { el: 'polygon', points: o.points.map((p) => snapPoint(p, g)), ...styleOf(o) }; } export function path(o: { d: string } & DrawStyle): DrawPrimitive { return { el: 'path', d: o.d, ...styleOf(o) }; } export function text( o: WithGrid<{ x: number; y: number; content: string } & TextAttrs & DrawStyle> ): DrawPrimitive { const g = o.grid ?? 0; const t: DrawPrimitive = { el: 'text', x: snap(o.x, g), y: snap(o.y, g), content: o.content, ...styleOf(o), }; const attrs = t as TextAttrs; if (o.fontSize !== undefined) attrs.fontSize = o.fontSize; if (o.fontFamily !== undefined) attrs.fontFamily = o.fontFamily; if (o.fontWeight !== undefined) attrs.fontWeight = o.fontWeight; if (o.textAnchor !== undefined) attrs.textAnchor = o.textAnchor; if (o.dominantBaseline !== undefined) attrs.dominantBaseline = o.dominantBaseline; if (o.letterSpacing !== undefined) attrs.letterSpacing = o.letterSpacing; return t; } export function group( children: DrawPrimitive[], o: { transform?: string; opacity?: number; id?: string; filter?: string; mask?: string; clipPath?: string; mixBlendMode?: string; } = {} ): DrawPrimitive { const out = { el: 'group' as const, children } as DrawPrimitive & { transform?: string; opacity?: number; id?: string; filter?: string; mask?: string; clipPath?: string; mixBlendMode?: string; }; if (o.transform !== undefined) out.transform = o.transform; if (o.opacity !== undefined) out.opacity = o.opacity; if (o.id !== undefined) out.id = o.id; if (o.filter !== undefined) out.filter = o.filter; if (o.mask !== undefined) out.mask = o.mask; if (o.clipPath !== undefined) out.clipPath = o.clipPath; if (o.mixBlendMode !== undefined) out.mixBlendMode = o.mixBlendMode; return out; } export function defs(children: DrawPrimitive[]): DrawPrimitive { return { el: 'defs', children }; } export function symbol(id: string, children: DrawPrimitive[], viewBox?: string): DrawPrimitive { const out: DrawPrimitive = { el: 'symbol', id, children }; if (viewBox !== undefined) (out as { viewBox?: string }).viewBox = viewBox; return out; } export function use( o: { href: string; x?: number; y?: number; width?: number; height?: number; transform?: string; } & DrawStyle ): DrawPrimitive { const out = { el: 'use' as const, href: o.href, ...styleOf(o) } as DrawPrimitive & { x?: number; y?: number; width?: number; height?: number; transform?: string; }; if (o.x !== undefined) out.x = o.x; if (o.y !== undefined) out.y = o.y; if (o.width !== undefined) out.width = o.width; if (o.height !== undefined) out.height = o.height; if (o.transform !== undefined) out.transform = o.transform; return out; } /** * A linear gradient definition (place inside `defs(...)` and reference with * `fill: 'url(#id)'`). Coords default to a top→bottom sweep in objectBoundingBox * space — exactly what a sky/backdrop wants. */ export function linearGradient(o: { id: string; stops: GradientStop[]; x1?: number; y1?: number; x2?: number; y2?: number; gradientUnits?: GradientUnits; }): DrawPrimitive { const g = { el: 'linearGradient' as const, id: o.id, stops: o.stops }; const out = g as DrawPrimitive & { x1?: number; y1?: number; x2?: number; y2?: number; gradientUnits?: GradientUnits; }; if (o.x1 !== undefined) out.x1 = o.x1; if (o.y1 !== undefined) out.y1 = o.y1; if (o.x2 !== undefined) out.x2 = o.x2; if (o.y2 !== undefined) out.y2 = o.y2; if (o.gradientUnits !== undefined) out.gradientUnits = o.gradientUnits; return out; } /** A radial gradient definition (e.g. a sun/glow). Reference with `fill: 'url(#id)'`. */ export function radialGradient(o: { id: string; stops: GradientStop[]; cx?: number; cy?: number; r?: number; fx?: number; fy?: number; gradientUnits?: GradientUnits; }): DrawPrimitive { const g = { el: 'radialGradient' as const, id: o.id, stops: o.stops }; const out = g as DrawPrimitive & { cx?: number; cy?: number; r?: number; fx?: number; fy?: number; gradientUnits?: GradientUnits; }; if (o.cx !== undefined) out.cx = o.cx; if (o.cy !== undefined) out.cy = o.cy; if (o.r !== undefined) out.r = o.r; if (o.fx !== undefined) out.fx = o.fx; if (o.fy !== undefined) out.fy = o.fy; if (o.gradientUnits !== undefined) out.gradientUnits = o.gradientUnits; return out; } // ───────────────────────────────────────────────────────────────────────────── // Filters / patterns / masks / clips — the rest of the legit design toolkit. // Place each inside `defs(...)` and reference it from a style field: // filter → `filter: 'url(#id)'` · pattern → `fill: 'url(#id)'` // mask → `mask: 'url(#id)'` · clipPath → `clipPath: 'url(#id)'` // ───────────────────────────────────────────────────────────────────────────── /** A generic filter primitive — `fe('feGaussianBlur', { stdDeviation: 4 })`. */ export function fe( tag: string, attrs?: Record, children?: FePrimitive[] ): FePrimitive { const out: FePrimitive = { fe: tag }; if (attrs) out.attrs = attrs; if (children) out.children = children; return out; } /** A `` def assembled from filter primitives (build them with {@link fe}). */ export function filter( id: string, prims: FePrimitive[], region?: { x?: string | number; y?: string | number; width?: string | number; height?: string | number; colorInterpolationFilters?: 'sRGB' | 'linearRGB'; } ): DrawPrimitive { const out = { el: 'filter' as const, id, prims } as DrawPrimitive & { x?: string | number; y?: string | number; width?: string | number; height?: string | number; colorInterpolationFilters?: 'sRGB' | 'linearRGB'; }; if (region?.x !== undefined) out.x = region.x; if (region?.y !== undefined) out.y = region.y; if (region?.width !== undefined) out.width = region.width; if (region?.height !== undefined) out.height = region.height; if (region?.colorInterpolationFilters !== undefined) out.colorInterpolationFilters = region.colorInterpolationFilters; return out; } /** Convenience: a Gaussian-blur filter. */ export function blurFilter(id: string, stdDeviation: number): DrawPrimitive { return filter(id, [fe('feGaussianBlur', { in: 'SourceGraphic', stdDeviation })]); } /** Convenience: a soft drop-shadow filter (a generous filter region by default). */ export function dropShadowFilter( id: string, o: { dx?: number; dy?: number; blur?: number; color?: string; opacity?: number } = {} ): DrawPrimitive { const attrs: Record = { dx: o.dx ?? 0, dy: o.dy ?? 2, stdDeviation: o.blur ?? 3, floodColor: o.color ?? '#000000', floodOpacity: o.opacity ?? 0.3, }; return filter(id, [fe('feDropShadow', attrs)], { x: '-30%', y: '-30%', width: '160%', height: '160%', }); } /** * Convenience: a film-grain / noise overlay filter (feTurbulence → desaturate → * fade). Apply to a full-bleed rect with a low opacity + `mix-blend-mode` for a * tasteful textured overlay. */ export function grainFilter( id: string, o: { frequency?: number; octaves?: number; opacity?: number } = {} ): DrawPrimitive { const freq = o.frequency ?? 0.9; const oct = o.octaves ?? 2; const op = o.opacity ?? 0.5; return filter(id, [ fe('feTurbulence', { type: 'fractalNoise', baseFrequency: freq, numOctaves: oct, stitchTiles: 'stitch', result: 'noise', }), fe('feColorMatrix', { in: 'noise', type: 'saturate', values: 0 }), fe('feComponentTransfer', undefined, [fe('feFuncA', { type: 'linear', slope: op })]), ]); } /** A tiled `` def. Build the tile from any primitives (dots, stripes, grid). */ export function pattern(o: { id: string; width: number; height: number; children: DrawPrimitive[]; patternUnits?: GradientUnits; patternTransform?: string; }): DrawPrimitive { const out = { el: 'pattern' as const, id: o.id, width: o.width, height: o.height, children: o.children, } as DrawPrimitive & { patternUnits?: GradientUnits; patternTransform?: string }; if (o.patternUnits !== undefined) out.patternUnits = o.patternUnits; if (o.patternTransform !== undefined) out.patternTransform = o.patternTransform; return out; } /** A `` def (white shows, black hides) — vignettes, edge fades, scrims. */ export function mask(id: string, children: DrawPrimitive[]): DrawPrimitive { return { el: 'mask', id, children }; } /** A `` def. */ export function clipPath(id: string, children: DrawPrimitive[]): DrawPrimitive { return { el: 'clipPath', id, children }; } // ───────────────────────────────────────────────────────────────────────────── // Transform composition // ───────────────────────────────────────────────────────────────────────────── export interface PlaceOpts { x?: number; y?: number; scale?: number; /** Degrees, clockwise (SVG convention). */ rotate?: number; /** Rotation pivot (defaults to the local origin). */ originX?: number; originY?: number; } /** Round transform numbers to avoid `0.30000000000000004`-style noise. */ function fmt(n: number): string { return Number.isInteger(n) ? String(n) : String(Math.round(n * 1e4) / 1e4); } /** * Build an SVG `transform` string in the canonical order * `translate → rotate → scale`. Each part is emitted only when it's non-identity * so trivial placements stay clean. Deterministic; no floating noise. */ export function transformString(o: PlaceOpts): string { const parts: string[] = []; const x = o.x ?? 0; const y = o.y ?? 0; if (x !== 0 || y !== 0) parts.push(`translate(${fmt(x)} ${fmt(y)})`); if (o.rotate) { if (o.originX !== undefined || o.originY !== undefined) { parts.push(`rotate(${fmt(o.rotate)} ${fmt(o.originX ?? 0)} ${fmt(o.originY ?? 0)})`); } else { parts.push(`rotate(${fmt(o.rotate)})`); } } const s = o.scale ?? 1; if (s !== 1) parts.push(`scale(${fmt(s)})`); return parts.join(' '); } /** * Compose a sub-drawing by wrapping it in a ``. The single * highest-leverage composition primitive: parts are authored once at the origin * and *placed* — no manual coordinate arithmetic, which is exactly the LLM * failure mode (coordinate drift) the engine removes. */ export function place(part: DrawPrimitive[], opts: PlaceOpts): DrawPrimitive { return group(part, { transform: transformString(opts) }); } // ───────────────────────────────────────────────────────────────────────────── // viewBox presets // ───────────────────────────────────────────────────────────────────────────── /** A canonical square viewBox `0 0 n n`. */ export function squareViewBox(n: number): string { return `0 0 ${n} ${n}`; } /** An arbitrary `0 0 w h` viewBox. */ export function boxViewBox(w: number, h: number): string { return `0 0 ${w} ${h}`; } /** * Common viewBox presets. `icon` = the 24-grid Material/Lucide standard; * `iconLg` = 48-grid; `logo` = 64-grid lockup; `social` = wide marketing card. */ export const VIEWBOX = { icon: squareViewBox(24), iconLg: squareViewBox(48), logo: squareViewBox(64), social: boxViewBox(1200, 630), } as const;