diff --git a/CHANGELOG.md b/CHANGELOG.md index eaeb0de22..4ab0b1f08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ - Add JSX authoring support for components, component sets, and instances. - Add type-validated `bindVariable`/`unbindVariable` with event emission and indexed binding format (`fills/N/color` instead of `fills[N]`). - Add `unbind_variable` MCP tool for removing variable bindings. +- Add `openpencil analyze overlaps`, the `analyze_overlaps` RPC command, and the `analyze_overlaps` ToolDef for heuristic overlap detection. The command reports sibling overlaps, children overflowing non-clipping parents, and overlay/backdrop patterns, with filters for page/page ID, scope, category, severity, min area/ratio, node type, hidden/locked/absolute nodes, result limit, and `--json` output. +- Add overlap analysis exports for automation consumers, including `computeOverlaps`, `analyzeOverlaps`, overlap result types, and parameter parsers from core subpath exports. +- Add world-matrix visual bounds to overlap analysis, covering vector/stroke/text geometry, ancestor clipping, rotated clipping frames, and nested ancestor rotations. +- Add the `@open-pencil/core/package.json` subpath export for package metadata consumers. ### Fixes @@ -18,6 +22,7 @@ - Improve Figma boolean imports by preserving XOR operations as editable exclude nodes and falling back to imported fill geometry when boolean path reconstruction cannot produce a path. - Preserve rotated Figma transform origins for imported vector nodes. - Render complex text fills through vector glyph outlines so imported Figma text can use the normal fill pipeline for gradients, images, patterns, and other non-solid paints. +- Fix file-backed CLI commands (`convert`, `eval --output`, `export`) to use Node `fs/promises` instead of Bun runtime APIs, so the published CLI works when installed and run under Node. ## 0.13.2 — 2026-05-30 diff --git a/README.md b/README.md index f2c3b6fa1..a369dcdce 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,7 @@ openpencil analyze colors design.fig openpencil analyze typography design.fig openpencil analyze spacing design.fig openpencil analyze clusters design.fig +openpencil analyze overlaps design.fig openpencil variables design.fig ``` diff --git a/packages/cli/src/commands/analyze/index.ts b/packages/cli/src/commands/analyze/index.ts index a4cb7818f..39b0543b4 100644 --- a/packages/cli/src/commands/analyze/index.ts +++ b/packages/cli/src/commands/analyze/index.ts @@ -2,6 +2,7 @@ import { defineCommand } from 'citty' import clusters from './clusters' import colors from './colors' +import overlaps from './overlaps' import spacing from './spacing' import typography from './typography' @@ -11,6 +12,7 @@ export default defineCommand({ colors, typography, spacing, - clusters + clusters, + overlaps } }) diff --git a/packages/cli/src/commands/analyze/overlaps.ts b/packages/cli/src/commands/analyze/overlaps.ts new file mode 100644 index 000000000..6f5ed9c00 --- /dev/null +++ b/packages/cli/src/commands/analyze/overlaps.ts @@ -0,0 +1,264 @@ +import { defineCommand } from 'citty' + +import type { AnalyzeOverlapsResult } from '@open-pencil/core/rpc' +import { + VALID_OVERLAP_CATEGORIES, + VALID_OVERLAP_SCOPES, + VALID_OVERLAP_SEVERITIES, + parseOverlapCategories, + parseOverlapScope, + parseOverlapSeverity +} from '@open-pencil/core/tools' + +import { bold, fail, fmtList, fmtSummary, kv } from '#cli/format' +import { loadRpcData } from '#cli/rpc-data' + +function validateScope(scope: string): string | undefined { + const normalized = scope.toLowerCase() + return (VALID_OVERLAP_SCOPES as readonly string[]).includes(normalized) + ? undefined + : `Invalid scope "${scope}". Must be one of: ${[...VALID_OVERLAP_SCOPES].join(', ')}` +} + +function validateSeverity(severity: string): string | undefined { + const normalized = severity.toLowerCase() + return (VALID_OVERLAP_SEVERITIES as readonly string[]).includes(normalized) + ? undefined + : `Invalid severity "${severity}". Must be one of: ${[...VALID_OVERLAP_SEVERITIES].join(', ')}` +} + +function validateMinRatio(minRatio: number | undefined): string | undefined { + if (minRatio === undefined) return undefined + if (Number.isNaN(minRatio) || minRatio < 0 || minRatio > 1) { + return '--min-ratio must be a number between 0.0 and 1.0' + } + return undefined +} + +function validateLimit(limit: string): string | undefined { + const value = Number(limit) + if (!Number.isFinite(value) || !Number.isInteger(value) || value <= 0) { + return '--limit must be a positive integer' + } + return undefined +} + +function validateMinArea(minArea: string): string | undefined { + const value = Number(minArea) + if (!Number.isFinite(value) || value < 0) { + return '--min-area must be a non-negative number' + } + return undefined +} + +function validateCategory(category: string): string | undefined { + const values = category + .split(',') + .map((c) => c.trim().toLowerCase()) + .filter((c) => c.length > 0) + if (values.length === 0) return undefined + const validCount = (parseOverlapCategories(category) ?? []).length + if (validCount === 0) { + return `Invalid categories: ${values.join(', ')}. Must be one or more of: ${[...VALID_OVERLAP_CATEGORIES].join(', ')}` + } + const invalid = values.filter((c) => !(VALID_OVERLAP_CATEGORIES as readonly string[]).includes(c)) + if (invalid.length > 0) { + return `Invalid categor${invalid.length === 1 ? 'y' : 'ies'}: ${invalid.join(', ')}. Must be one or more of: ${[...VALID_OVERLAP_CATEGORIES].join(', ')}` + } + return undefined +} + +function collectValidationError(args: { + scope: string + severity: string + category?: string + limit: string + 'min-area'?: string + 'min-ratio'?: string +}): string | undefined { + return ( + validateScope(args.scope) ?? + validateSeverity(args.severity) ?? + validateLimit(args.limit) ?? + (args.category ? validateCategory(args.category) : undefined) ?? + (args['min-area'] !== undefined ? validateMinArea(args['min-area']) : undefined) ?? + validateMinRatio(args['min-ratio'] ? Number(args['min-ratio']) : undefined) + ) +} + +function buildRpcArgs(args: { + scope: string + severity: string + category?: string + limit: string + 'min-area'?: string + 'min-ratio'?: string + 'include-hidden'?: boolean + 'include-locked'?: boolean + 'include-absolute'?: boolean + page?: string + 'page-id'?: string + type?: string +}): { rpcArgs: Record; error?: string } { + const error = collectValidationError(args) + if (error) return { rpcArgs: {}, error } + + const minAreaRaw = args['min-area'] + const minArea = minAreaRaw ? Number(minAreaRaw) : undefined + const minRatio = args['min-ratio'] ? Number(args['min-ratio']) : undefined + + const rpcArgs: Record = { + scope: parseOverlapScope(args.scope) ?? 'all', + severity: parseOverlapSeverity(args.severity) ?? 'info', + limit: Number(args.limit) + } + if (args.category) rpcArgs.category = args.category + if (minArea !== undefined) rpcArgs.min_area = minArea + if (minRatio !== undefined) rpcArgs.min_ratio = minRatio + if (args['include-hidden']) rpcArgs.include_hidden = true + if (args['include-locked']) rpcArgs.include_locked = true + if (args['include-absolute']) rpcArgs.include_absolute = true + if (args.page) rpcArgs.page = args.page + if (args['page-id']) rpcArgs.page_id = args['page-id'] + if (args.type) rpcArgs.type = args.type + + return { rpcArgs } +} + +export default defineCommand({ + meta: { description: 'Detect visual overlaps and layout overflows' }, + args: { + file: { + type: 'positional', + description: '.fig file path (omit to connect to running app)', + required: false + }, + scope: { + type: 'string', + description: + 'Which pairs to inspect: all, same-parent, cross-parent, top-level, inside-parent', + default: 'all' + }, + category: { + type: 'string', + description: 'Comma-separated categories: sibling-overlap, parent-overflow, overlay' + }, + severity: { + type: 'string', + description: 'Minimum severity to include: critical, major, minor, info', + default: 'info' + }, + 'min-area': { + type: 'string', + description: 'Minimum overlap area in square pixels' + }, + 'min-ratio': { + type: 'string', + description: 'Minimum overlap ratio relative to the smaller node, 0.0–1.0' + }, + 'include-hidden': { + type: 'boolean', + description: 'Include hidden nodes in the analysis' + }, + 'include-locked': { + type: 'boolean', + description: 'Include locked nodes in the analysis' + }, + 'include-absolute': { + type: 'boolean', + description: 'Include absolutely-positioned nodes in the analysis' + }, + page: { + type: 'string', + description: 'Limit analysis to nodes on the named page' + }, + 'page-id': { + type: 'string', + description: 'Limit analysis to nodes on the page with this stable ID' + }, + type: { + type: 'string', + description: 'Comma-separated node types to analyze, e.g. FRAME,TEXT' + }, + limit: { + type: 'string', + description: 'Maximum overlap findings to show', + default: '100' + }, + json: { type: 'boolean', description: 'Output as JSON' } + }, + async run({ args }) { + const { rpcArgs, error } = buildRpcArgs(args) + if (error) { + console.error(fail(error)) + process.exit(1) + } + + const data = await loadRpcData(args.file, 'analyze_overlaps', rpcArgs) + + if (args.json) { + console.log(JSON.stringify(data, null, 2)) + return + } + + if (data.summary.overlapCount === 0) { + console.log(kv('status', 'No overlaps found')) + console.log(fmtSummary({ 'analyzed nodes': data.summary.analyzedNodes })) + return + } + + const showing = data.overlaps.length + const total = data.summary.overlapCount + const header = + showing === total + ? ` Overlaps — ${total} found` + : ` Overlaps — ${total} found (${showing} shown)` + + console.log('') + console.log(bold(header)) + console.log('') + + console.log( + fmtList( + data.overlaps.map((overlap) => ({ + header: `[${overlap.severity}] ${overlap.category}`, + details: { + message: overlap.message, + area: `${overlap.area}px`, + ratio: `${(overlap.ratio * 100).toFixed(1)}%`, + a: `${overlap.nodeA.type} "${overlap.nodeA.name}" (${overlap.nodeA.id})`, + b: `${overlap.nodeB.type} "${overlap.nodeB.name}" (${overlap.nodeB.id})`, + suggestion: overlap.suggestion + } + })) + ) + ) + + console.log('') + console.log( + fmtSummary({ + 'analyzed nodes': data.summary.analyzedNodes, + 'total nodes': data.summary.totalNodes + }) + ) + console.log( + kv( + 'by category', + Object.entries(data.summary.byCategory) + .filter(([, count]) => count > 0) + .map(([k, count]) => `${k}: ${count}`) + .join(', ') || 'none' + ) + ) + console.log( + kv( + 'by severity', + Object.entries(data.summary.bySeverity) + .filter(([, count]) => count > 0) + .map(([k, count]) => `${k}: ${count}`) + .join(', ') || 'none' + ) + ) + console.log('') + } +}) diff --git a/packages/cli/src/commands/convert.ts b/packages/cli/src/commands/convert.ts index d52681e87..a87c4b12b 100644 --- a/packages/cli/src/commands/convert.ts +++ b/packages/cli/src/commands/convert.ts @@ -1,3 +1,4 @@ +import { writeFile } from 'node:fs/promises' import { basename, extname, resolve } from 'node:path' import { defineCommand } from 'citty' @@ -52,7 +53,7 @@ export default defineCommand({ const graph = await loadDocument(file) const result = await io.writeDocument(format, graph) const output = args.output ? resolve(args.output) : defaultOutput(file, format) - await Bun.write(output, result.data as Uint8Array) + await writeFile(output, result.data as Uint8Array) console.log(ok(`Converted ${file} → ${output}`)) } }) diff --git a/packages/cli/src/commands/eval.ts b/packages/cli/src/commands/eval.ts index e51ea99a3..32fa55311 100644 --- a/packages/cli/src/commands/eval.ts +++ b/packages/cli/src/commands/eval.ts @@ -1,3 +1,5 @@ +import { writeFile } from 'node:fs/promises' + import { defineCommand } from 'citty' import { FigmaAPI } from '@open-pencil/core/figma-api' @@ -96,7 +98,7 @@ export default defineCommand({ const io = new IORegistry(BUILTIN_IO_FORMATS) const outPath = args.output ? args.output : file const result = await io.writeDocument('fig', graph) - await Bun.write(outPath, result.data as Uint8Array) + await writeFile(outPath, result.data as Uint8Array) if (!args.quiet) { console.error(`Written to ${outPath}`) } diff --git a/packages/cli/src/commands/export.ts b/packages/cli/src/commands/export.ts index f3eb47ee2..7ed1a0c16 100644 --- a/packages/cli/src/commands/export.ts +++ b/packages/cli/src/commands/export.ts @@ -1,3 +1,4 @@ +import { writeFile } from 'node:fs/promises' import { basename, extname, resolve } from 'node:path' import { defineCommand } from 'citty' @@ -29,7 +30,7 @@ interface ExportArgs { } async function writeAndLog(path: string, content: string | Uint8Array) { - await Bun.write(path, content) + await writeFile(path, content) const size = typeof content === 'string' ? content.length : content.length console.log(ok(`Exported ${path} (${(size / 1024).toFixed(1)} KB)`)) } diff --git a/packages/cli/src/headless.ts b/packages/cli/src/headless.ts index e76217316..f6cae27c8 100644 --- a/packages/cli/src/headless.ts +++ b/packages/cli/src/headless.ts @@ -1,3 +1,5 @@ +import { readFile } from 'node:fs/promises' + import { BUILTIN_IO_FORMATS, IORegistry, initCanvasKit } from '@open-pencil/core/io' import { computeAllLayouts } from '@open-pencil/core/layout' import type { SceneGraph } from '@open-pencil/core/scene-graph' @@ -7,7 +9,7 @@ export { initCanvasKit } const io = new IORegistry(BUILTIN_IO_FORMATS) export async function loadDocument(filePath: string): Promise { - const bytes = new Uint8Array(await Bun.file(filePath).arrayBuffer()) + const bytes = new Uint8Array(await readFile(filePath)) const { graph } = await io.readDocument({ name: filePath, data: bytes }) computeAllLayouts(graph) return graph diff --git a/packages/core/package.json b/packages/core/package.json index 1a34dce99..bfdf83aae 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -8,6 +8,7 @@ }, "sideEffects": false, "exports": { + "./package.json": "./package.json", ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js", diff --git a/packages/core/src/geometry.ts b/packages/core/src/geometry.ts index 4150c7a6a..d13e44f96 100644 --- a/packages/core/src/geometry.ts +++ b/packages/core/src/geometry.ts @@ -137,7 +137,7 @@ export function polygonVertices(node: { }) } -function strokeOverflow(strokes?: Stroke[]): number { +export function strokeOverflow(strokes?: Stroke[]): number { let overflow = 0 for (const stroke of strokes ?? []) { if (!stroke.visible) continue @@ -149,7 +149,7 @@ function strokeOverflow(strokes?: Stroke[]): number { return overflow } -function effectOverflow(effects?: Effect[]) { +export function effectOverflow(effects?: Effect[]) { let left = 0 let right = 0 let top = 0 @@ -398,3 +398,98 @@ export function computeDescendantVisualBounds( } return bounds } + +// ── Rotated-rectangle clipping ── + +function crossProduct(a: Vector, b: Vector, p: Vector): number { + return (b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x) +} + +function lineSegmentIntersect(p1: Vector, p2: Vector, p3: Vector, p4: Vector): Vector { + const dx1 = p2.x - p1.x + const dy1 = p2.y - p1.y + const dx2 = p4.x - p3.x + const dy2 = p4.y - p3.y + const denom = dx1 * dy2 - dy1 * dx2 + if (denom === 0) return p1 + const t = ((p3.x - p1.x) * dy2 - (p3.y - p1.y) * dx2) / denom + return { x: p1.x + t * dx1, y: p1.y + t * dy1 } +} + +function clipHalfPlane(polygon: Vector[], a: Vector, b: Vector, wantPositive: boolean): Vector[] { + const output: Vector[] = [] + const isInside = (p: Vector): boolean => { + const cross = crossProduct(a, b, p) + return wantPositive ? cross >= 0 : cross <= 0 + } + for (let i = 0; i < polygon.length; i++) { + const curr = polygon[i] + const prev = polygon[i === 0 ? polygon.length - 1 : i - 1] + const currInside = isInside(curr) + const prevInside = isInside(prev) + if (currInside) { + if (!prevInside) output.push(lineSegmentIntersect(prev, curr, a, b)) + output.push(curr) + } else if (prevInside) { + output.push(lineSegmentIntersect(prev, curr, a, b)) + } + } + return output +} + +/** + * Clip an axis-aligned VisualBounds rectangle against a convex polygon + * (e.g. the 4 canvas-space corners of a rotated clipping ancestor). + * + * Uses Sutherland–Hodgman polygon clipping with centroid-based interior + * detection, making it robust to either winding order of the clip polygon. + * Returns the AABB of the intersection, or null if the bounds are fully + * outside the clip polygon. + * + * For a non-rotated clip (axis-aligned corners) the result is identical + * to `intersectVisualBounds`. + */ +export function clipBoundsToPolygon( + bounds: VisualBounds, + clipCorners: Vector[] +): VisualBounds | null { + if (clipCorners.length < 3) return bounds + + let cx = 0 + let cy = 0 + for (const c of clipCorners) { + cx += c.x + cy += c.y + } + cx /= clipCorners.length + cy /= clipCorners.length + + let subject: Vector[] = [ + { x: bounds.minX, y: bounds.minY }, + { x: bounds.maxX, y: bounds.minY }, + { x: bounds.maxX, y: bounds.maxY }, + { x: bounds.minX, y: bounds.maxY } + ] + + for (let i = 0; i < clipCorners.length; i++) { + if (subject.length === 0) return null + const a = clipCorners[i] + const b = clipCorners[(i + 1) % clipCorners.length] + const centroidCross = crossProduct(a, b, { x: cx, y: cy }) + subject = clipHalfPlane(subject, a, b, centroidCross >= 0) + } + + if (subject.length === 0) return null + + let minX = Infinity + let minY = Infinity + let maxX = -Infinity + let maxY = -Infinity + for (const p of subject) { + if (p.x < minX) minX = p.x + if (p.y < minY) minY = p.y + if (p.x > maxX) maxX = p.x + if (p.y > maxY) maxY = p.y + } + return minX < maxX && minY < maxY ? { minX, minY, maxX, maxY } : null +} diff --git a/packages/core/src/rpc/analyze-commands.ts b/packages/core/src/rpc/analyze-commands.ts index 3c624b32f..1a9439860 100644 --- a/packages/core/src/rpc/analyze-commands.ts +++ b/packages/core/src/rpc/analyze-commands.ts @@ -3,10 +3,17 @@ import { orderBy, sortBy } from 'es-toolkit/array' import { colorToHex, colorDistance as colorDist } from '#core/color' import type { ColorUsageEntry } from '#core/color/analysis' import type { SceneGraph, SceneNode } from '#core/scene-graph' +import { + computeOverlaps, + type AnalyzeOverlapsArgs, + type AnalyzeOverlapsResult +} from '#core/tools/analyze/overlaps' import type { Color } from '#core/types' import type { RpcCommand } from './types' +export type { AnalyzeOverlapsArgs, AnalyzeOverlapsResult } from '#core/tools/analyze/overlaps' + // ── analyze colors ── export interface AnalyzeColorsArgs { @@ -297,3 +304,10 @@ export const analyzeClustersCommand: RpcCommand = { + name: 'analyze_overlaps', + execute: (graph, args) => computeOverlaps(graph, args) +} diff --git a/packages/core/src/rpc/commands.ts b/packages/core/src/rpc/commands.ts index 4a0336a66..fa9c1f741 100644 --- a/packages/core/src/rpc/commands.ts +++ b/packages/core/src/rpc/commands.ts @@ -3,6 +3,7 @@ import type { SceneGraph } from '#core/scene-graph' import { analyzeClustersCommand, analyzeColorsCommand, + analyzeOverlapsCommand, analyzeSpacingCommand, analyzeTypographyCommand } from './analyze-commands' @@ -33,7 +34,8 @@ export const ALL_RPC_COMMANDS = [ analyzeColorsCommand, analyzeTypographyCommand, analyzeSpacingCommand, - analyzeClustersCommand + analyzeClustersCommand, + analyzeOverlapsCommand ] as RpcCommand[] export function executeRpcCommand(graph: SceneGraph, name: string, args: unknown): unknown { diff --git a/packages/core/src/rpc/index.ts b/packages/core/src/rpc/index.ts index b3a239679..ba8d0e7f2 100644 --- a/packages/core/src/rpc/index.ts +++ b/packages/core/src/rpc/index.ts @@ -21,5 +21,7 @@ export type { SpacingValue, AnalyzeClustersArgs, AnalyzeClustersResult, + AnalyzeOverlapsArgs, + AnalyzeOverlapsResult, TypographyStyle } from './commands' diff --git a/packages/core/src/tools/analyze.ts b/packages/core/src/tools/analyze.ts index 5eab2bf34..0b2d13571 100644 --- a/packages/core/src/tools/analyze.ts +++ b/packages/core/src/tools/analyze.ts @@ -3,5 +3,14 @@ export { analyzeColors } from './analyze/colors' export { diffCreate, diffShow } from './analyze/diff' export { evalCode } from './analyze/eval' export { wrapEvalCode } from './analyze/eval/wrap' +export { analyzeOverlaps, computeOverlaps } from './analyze/overlaps' +export type { + AnalyzeOverlapsArgs, + AnalyzeOverlapsResult, + OverlapCategory, + OverlapItem, + OverlapNodeSummary, + OverlapSeverity +} from './analyze/overlaps' export { analyzeSpacing } from './analyze/spacing' export { analyzeTypography } from './analyze/typography' diff --git a/packages/core/src/tools/analyze/overlaps/helpers.ts b/packages/core/src/tools/analyze/overlaps/helpers.ts new file mode 100644 index 000000000..637ecf841 --- /dev/null +++ b/packages/core/src/tools/analyze/overlaps/helpers.ts @@ -0,0 +1,551 @@ +import { getWorldMatrix } from '#core/canvas/coordinate' +import Matrix from '#core/canvas/matrix' +import { + clipBoundsToPolygon, + effectOverflow, + geometryBlobBounds, + intersectVisualBounds, + strokeOverflow, + unionVisualBounds, + type VisualBounds +} from '#core/geometry' +import type { SceneGraph, SceneNode } from '#core/scene-graph' +import type { Rect, Vector } from '#core/types' + +import type { + AnalyzeOverlapsArgs, + OverlapCategory, + OverlapItem, + OverlapNodeSummary, + OverlapScope, + OverlapSeverity +} from './index' +const SEVERITY_RANK: Record = { + critical: 4, + major: 3, + minor: 2, + info: 1 +} + +export function parseNodeTypes(raw: string | undefined): Set | undefined { + if (!raw) return undefined + const types = raw + .split(',') + .map((v) => v.trim().toUpperCase()) + .filter((v) => v.length > 0) + return types.length > 0 ? new Set(types) : undefined +} + +export function visualBoundsArea(bounds: VisualBounds): number { + const width = bounds.maxX - bounds.minX + const height = bounds.maxY - bounds.minY + return width > 0 && height > 0 ? width * height : 0 +} + +export function boundsToRect(bounds: VisualBounds): Rect { + return { + x: bounds.minX, + y: bounds.minY, + width: bounds.maxX - bounds.minX, + height: bounds.maxY - bounds.minY + } +} + +export function toNodeSummary(node: SceneNode): OverlapNodeSummary { + return { + id: node.id, + name: node.name, + type: node.type, + parentId: node.parentId, + x: Math.round(node.x), + y: Math.round(node.y), + width: Math.round(node.width), + height: Math.round(node.height), + rotation: Math.round(node.rotation), + opacity: node.opacity, + visible: node.visible, + locked: node.locked + } +} + +export function isEffectivelyHidden(graph: SceneGraph, node: SceneNode): boolean { + let current: SceneNode | undefined = node + while (current) { + if (!current.visible) return true + current = current.parentId ? graph.getNode(current.parentId) : undefined + } + return false +} + +export function isEffectivelyLocked(graph: SceneGraph, node: SceneNode): boolean { + let current: SceneNode | undefined = node + while (current) { + if (current.locked) return true + current = current.parentId ? graph.getNode(current.parentId) : undefined + } + return false +} + +export function findPageId(graph: SceneGraph, node: SceneNode): string | null { + let current: SceneNode | undefined = node + while (current) { + if (current.type === 'CANVAS') return current.id + if (current.parentId === null) return null + current = graph.getNode(current.parentId) + } + return null +} + +export function findPageIdByName(graph: SceneGraph, name: string | undefined): string | undefined { + if (!name) return undefined + const page = graph.getPages().find((p) => p.name === name) + return page?.id +} + +export function pairRelationship( + nodeA: SceneNode, + nodeB: SceneNode, + graph: SceneGraph +): { + sameParent: boolean + topLevel: boolean + insideParent: boolean + ancestor: 'neither' | 'a-ancestor' | 'b-ancestor' +} { + const sameParent = nodeA.parentId === nodeB.parentId && nodeA.parentId !== null + const parentA = nodeA.parentId ? graph.getNode(nodeA.parentId) : undefined + const parentB = nodeB.parentId ? graph.getNode(nodeB.parentId) : undefined + const topLevel = parentA?.type === 'CANVAS' && parentB?.type === 'CANVAS' + const insideParent = sameParent && parentA?.type !== 'CANVAS' + let ancestor: 'neither' | 'a-ancestor' | 'b-ancestor' = 'neither' + if (nodeA.id !== nodeB.id) { + if (graph.isDescendant(nodeB.id, nodeA.id)) ancestor = 'a-ancestor' + else if (graph.isDescendant(nodeA.id, nodeB.id)) ancestor = 'b-ancestor' + } + return { sameParent, topLevel, insideParent, ancestor } +} + +export function matchesParentOverflowScope(scope: OverlapScope): boolean { + return scope === 'all' || scope === 'inside-parent' +} + +export function matchesScope( + rel: ReturnType, + scope: OverlapScope +): boolean { + switch (scope) { + case 'all': + return true + case 'same-parent': + return rel.sameParent + case 'cross-parent': + return !rel.sameParent + case 'top-level': + return rel.topLevel + case 'inside-parent': + return rel.insideParent + default: + return true + } +} + +export function scoredSeverity(severity: OverlapSeverity): number { + return SEVERITY_RANK[severity] +} + +function parentOverflowSeverity(outRatio: number): OverlapSeverity { + if (outRatio > 0.25) return 'critical' + if (outRatio > 0.05) return 'major' + return 'minor' +} + +function siblingOverlapSeverity(intersectionArea: number, smallerArea: number): OverlapSeverity { + if (smallerArea <= 0) return 'info' + const ratio = intersectionArea / smallerArea + if (ratio > 0.5) return 'major' + if (ratio > 0.08) return 'minor' + return 'info' +} + +function isCandidate( + node: SceneNode, + graph: SceneGraph, + options: { + includeHidden: boolean + includeLocked: boolean + includeAbsolute: boolean + pageId: string | undefined + } +): boolean { + if (node.type === 'CANVAS') return false + if (!options.includeHidden && isEffectivelyHidden(graph, node)) return false + if (!options.includeLocked && isEffectivelyLocked(graph, node)) return false + if (!options.includeAbsolute && node.layoutPositioning === 'ABSOLUTE') return false + if (options.pageId && findPageId(graph, node) !== options.pageId) return false + return true +} + +export function filterNodes( + graph: SceneGraph, + args: AnalyzeOverlapsArgs +): { candidates: SceneNode[]; totalNodes: number; analyzedNodes: number } { + const includeHidden = args.include_hidden === true + const includeLocked = args.include_locked === true + const includeAbsolute = args.include_absolute === true + const pageIdFilter = args.page_id?.trim() + const typeFilter = parseNodeTypes(args.type) + + const allNodes = [...graph.getAllNodes()] + const candidates: SceneNode[] = [] + let totalNodes = 0 + + for (const node of allNodes) { + if (node.type === 'CANVAS') continue + // Apply page scoping to totalNodes so summary counts reflect the filtered universe. + if (pageIdFilter && findPageId(graph, node) !== pageIdFilter) continue + totalNodes++ + if ( + !isCandidate(node, graph, { + includeHidden, + includeLocked, + includeAbsolute, + pageId: undefined + }) + ) + continue + if (typeFilter && !typeFilter.has(node.type)) continue + candidates.push(node) + } + + return { candidates, totalNodes, analyzedNodes: candidates.length } +} + +const EMPTY_BOUNDS: VisualBounds = { minX: 0, maxX: 0, minY: 0, maxY: 0 } + +/** + * Map a node's 4 local corners through the world matrix to canvas space. + * Unlike `getAbsolutePosition` + `rotatedBBox` (which only applies the node's + * own rotation), this correctly accounts for all ancestor transforms + * including nested rotations. + */ +function nodeWorldCorners(node: SceneNode, graph: SceneGraph): Vector[] { + const matrix = getWorldMatrix(node, graph) + const pts = Matrix.mapPoints(matrix, [ + 0, + 0, + node.width, + 0, + node.width, + node.height, + 0, + node.height + ]) + return [ + { x: pts[0], y: pts[1] }, + { x: pts[2], y: pts[3] }, + { x: pts[4], y: pts[5] }, + { x: pts[6], y: pts[7] } + ] +} + +function aabbFromCorners(corners: Vector[]): VisualBounds { + let minX = Infinity + let minY = Infinity + let maxX = -Infinity + let maxY = -Infinity + for (const c of corners) { + if (c.x < minX) minX = c.x + if (c.y < minY) minY = c.y + if (c.x > maxX) maxX = c.x + if (c.y > maxY) maxY = c.y + } + return { minX, minY, maxX, maxY } +} + +/** + * Compute a node's visual bounds in canvas space using the world matrix, + * correctly handling nested ancestor rotations. This replaces the previous + * `nodeVisualBounds` call which used `rotatedBBox` with the node's LOCAL + * rotation and the rotated origin — wrong when ancestors are rotated. + * + * Includes stroke overflow (uniform), effect overflow (directional), + * fill/stroke geometry (transformed through the world matrix), and + * text-decoration overflow (approximate: adds to canvas maxY). + */ +function computeNodeVisualBounds(node: SceneNode, graph: SceneGraph): VisualBounds { + const matrix = getWorldMatrix(node, graph) + const baseCorners = Matrix.mapPoints(matrix, [ + 0, + 0, + node.width, + 0, + node.width, + node.height, + 0, + node.height + ]) + let bounds = aabbFromCorners([ + { x: baseCorners[0], y: baseCorners[1] }, + { x: baseCorners[2], y: baseCorners[3] }, + { x: baseCorners[4], y: baseCorners[5] }, + { x: baseCorners[6], y: baseCorners[7] } + ]) + + const stroke = strokeOverflow(node.strokes) + if (stroke > 0) { + bounds.minX -= stroke + bounds.minY -= stroke + bounds.maxX += stroke + bounds.maxY += stroke + } + + const effects = effectOverflow(node.effects) + bounds.minX -= effects.left + bounds.minY -= effects.top + bounds.maxX += effects.right + bounds.maxY += effects.bottom + + const hasNonInsideStroke = node.strokes.some( + (stroke) => stroke.visible && stroke.align !== 'INSIDE' + ) + const localGeometry = geometryBlobBounds([ + ...node.fillGeometry, + ...(hasNonInsideStroke ? node.strokeGeometry : []) + ]) + if (localGeometry) { + const geomCorners = Matrix.mapPoints(matrix, [ + localGeometry.x, + localGeometry.y, + localGeometry.x + localGeometry.width, + localGeometry.y, + localGeometry.x + localGeometry.width, + localGeometry.y + localGeometry.height, + localGeometry.x, + localGeometry.y + localGeometry.height + ]) + const geomBounds = aabbFromCorners([ + { x: geomCorners[0], y: geomCorners[1] }, + { x: geomCorners[2], y: geomCorners[3] }, + { x: geomCorners[4], y: geomCorners[5] }, + { x: geomCorners[6], y: geomCorners[7] } + ]) + bounds = unionVisualBounds(bounds, geomBounds) ?? bounds + } + + if (node.type === 'TEXT' && node.textDecoration !== 'NONE') { + const fontSize = node.fontSize + const underlineOffset = node.textUnderlineOffset ?? fontSize * 0.18 + const thickness = node.textDecorationThickness ?? Math.max(1, fontSize / 16) + bounds.maxY += underlineOffset + thickness + fontSize * 0.35 + } + + return bounds +} + +/** + * Collect clipping ancestors as rotated polygons (4 canvas-space corners each). + * Unlike the previous axis-aligned approach, this correctly represents rotated + * clip frames by mapping their local corners through the world matrix. + */ +function collectClipChain(graph: SceneGraph, node: SceneNode): Vector[][] { + const clips: Vector[][] = [] + let currentId = node.parentId + while (currentId) { + const current = graph.getNode(currentId) + if (!current) break + if (current.type === 'CANVAS') break + if ( + current.clipsContent && + (current.type === 'FRAME' || current.type === 'COMPONENT' || current.type === 'INSTANCE') + ) { + clips.push(nodeWorldCorners(current, graph)) + } + currentId = current.parentId + } + return clips +} + +export function computeNodeBounds( + node: SceneNode, + graph: SceneGraph +): { bounds: VisualBounds; area: number } { + let bounds = computeNodeVisualBounds(node, graph) + const clips = collectClipChain(graph, node) + for (const clip of clips) { + const clipped = clipBoundsToPolygon(bounds, clip) + if (!clipped) { + return { bounds: EMPTY_BOUNDS, area: 0 } + } + bounds = clipped + } + return { bounds, area: visualBoundsArea(bounds) } +} + +function makeOverlapItem( + category: OverlapCategory, + severity: OverlapSeverity, + nodeA: SceneNode, + boundsA: VisualBounds, + nodeB: SceneNode, + boundsB: VisualBounds, + intersection: VisualBounds, + message: string, + suggestion: string, + areaField: 'intersection' | 'overflow' = 'intersection' +): OverlapItem { + const intersectionRect = boundsToRect(intersection) + const area = + areaField === 'intersection' + ? visualBoundsArea(intersection) + : visualBoundsArea(boundsA) - visualBoundsArea(intersection) + const ratio = + areaField === 'intersection' + ? area / Math.max(1, Math.min(visualBoundsArea(boundsA), visualBoundsArea(boundsB))) + : area / Math.max(1, visualBoundsArea(boundsA)) + + return { + category, + severity, + message, + suggestion, + area: Math.round(area), + ratio: Math.round(ratio * 1000) / 1000, + nodeA: toNodeSummary(nodeA), + nodeB: toNodeSummary(nodeB), + intersection: { + ...intersectionRect, + area: Math.round(visualBoundsArea(intersection)) + } + } +} + +export function buildParentOverflowResult( + child: SceneNode, + childBounds: VisualBounds, + parent: SceneNode, + parentBounds: VisualBounds +): OverlapItem | null { + if (parent.clipsContent) return null + const childArea = visualBoundsArea(childBounds) + if (childArea <= 0) return null + + const intersection = intersectVisualBounds(childBounds, parentBounds) + const outArea = intersection ? childArea - visualBoundsArea(intersection) : childArea + if (outArea <= 0) return null + + const outRatio = outArea / childArea + const severity = parentOverflowSeverity(outRatio) + const message = `${child.type === 'TEXT' ? 'Text' : `Node`} "${child.name}" extends ${Math.round(outArea)}px outside parent "${parent.name}"` + const suggestion = + child.type === 'TEXT' + ? 'Set the parent to clip content or constrain text sizing (textAutoResize, maxLines).' + : `Reposition inside "${parent.name}" or enable clip content on the parent.` + + return makeOverlapItem( + 'parent-overflow', + severity, + child, + childBounds, + parent, + parentBounds, + intersection ?? EMPTY_BOUNDS, + message, + suggestion, + 'overflow' + ) +} + +function isNodeAbove(ancestorGraph: SceneGraph, above: SceneNode, below: SceneNode): boolean { + if (above.parentId !== below.parentId || above.parentId === null) return false + const parent = ancestorGraph.getNode(above.parentId) + if (!parent) return false + const aboveIndex = parent.childIds.indexOf(above.id) + const belowIndex = parent.childIds.indexOf(below.id) + return aboveIndex > belowIndex +} + +function detectSiblingOverlay( + nodeA: SceneNode, + boundsA: VisualBounds, + nodeB: SceneNode, + boundsB: VisualBounds, + graph: SceneGraph +): OverlapCategory { + const areaA = visualBoundsArea(boundsA) + const areaB = visualBoundsArea(boundsB) + const smallerArea = Math.min(areaA, areaB) + const largerArea = Math.max(areaA, areaB) + const intersection = intersectVisualBounds(boundsA, boundsB) + if (!intersection || smallerArea <= 0) return 'sibling-overlap' + const overlapArea = visualBoundsArea(intersection) + const coversSmall = overlapArea / smallerArea > 0.85 + const sizeRatio = largerArea / Math.max(1, smallerArea) + if (sizeRatio < 5 || !coversSmall) return 'sibling-overlap' + + const larger = areaA >= areaB ? nodeA : nodeB + const smaller = areaA >= areaB ? nodeB : nodeA + return isNodeAbove(graph, larger, smaller) ? 'overlay' : 'sibling-overlap' +} + +export function buildSiblingOverlapResult( + nodeA: SceneNode, + boundsA: VisualBounds, + nodeB: SceneNode, + boundsB: VisualBounds, + graph: SceneGraph +): OverlapItem | null { + const intersection = intersectVisualBounds(boundsA, boundsB) + if (!intersection) return null + + const areaA = visualBoundsArea(boundsA) + const areaB = visualBoundsArea(boundsB) + const smallerArea = Math.min(areaA, areaB) + const category = detectSiblingOverlay(nodeA, boundsA, nodeB, boundsB, graph) + const intersectionArea = visualBoundsArea(intersection) + let severity: OverlapSeverity + if (category === 'overlay') { + severity = smallerArea > 0 && intersectionArea / smallerArea > 0.98 ? 'info' : 'minor' + } else { + severity = siblingOverlapSeverity(intersectionArea, smallerArea) + } + + const larger = areaA >= areaB ? nodeA : nodeB + const smaller = areaA >= areaB ? nodeB : nodeA + + const message = + category === 'overlay' + ? `"${larger.name}" appears to be an overlay covering "${smaller.name}"` + : `"${nodeA.name}" overlaps "${nodeB.name}"` + const suggestion = + category === 'overlay' + ? 'If intentional (modal/backdrop/dropdown), no action needed. Otherwise reposition or adjust z-order.' + : 'Review stacking and spacing — this overlap is likely unintended.' + + return makeOverlapItem( + category, + severity, + nodeA, + boundsA, + nodeB, + boundsB, + intersection, + message, + suggestion + ) +} + +export function passesThresholds( + item: OverlapItem, + minArea: number, + minRatio: number, + categoryFilter: OverlapCategory[] | undefined, + severityFilter: OverlapSeverity | undefined +): boolean { + if (item.area < minArea) return false + if (item.ratio < minRatio) return false + if (categoryFilter && !categoryFilter.includes(item.category)) return false + if (severityFilter && scoredSeverity(item.severity) < scoredSeverity(severityFilter)) return false + return true +} + +export type BoundsEntry = { node: SceneNode; bounds: VisualBounds; area: number } diff --git a/packages/core/src/tools/analyze/overlaps/index.ts b/packages/core/src/tools/analyze/overlaps/index.ts new file mode 100644 index 000000000..6aef894e3 --- /dev/null +++ b/packages/core/src/tools/analyze/overlaps/index.ts @@ -0,0 +1,394 @@ +import { orderBy } from 'es-toolkit/array' + +import type { FigmaAPI } from '#core/figma-api' +import type { SceneGraph, SceneNode } from '#core/scene-graph' +import { defineTool } from '#core/tools/schema' + +import { + buildParentOverflowResult, + buildSiblingOverlapResult, + computeNodeBounds, + filterNodes, + findPageId, + findPageIdByName, + matchesParentOverflowScope, + matchesScope, + pairRelationship, + passesThresholds, + scoredSeverity, + type BoundsEntry +} from './helpers' +import { + parseOverlapCategories as parseCategoryFilter, + parseOverlapScope as toScope, + parseOverlapSeverity as parseSeverity +} from './params' + +export type OverlapSeverity = 'critical' | 'major' | 'minor' | 'info' + +export type OverlapScope = 'all' | 'same-parent' | 'cross-parent' | 'top-level' | 'inside-parent' + +export type OverlapCategory = 'sibling-overlap' | 'parent-overflow' | 'overlay' + +export { findPageId, findPageIdByName } + +export interface OverlapNodeSummary { + id: string + name: string + type: string + parentId: string | null + x: number + y: number + width: number + height: number + rotation: number + opacity: number + visible: boolean + locked: boolean +} + +export interface OverlapIntersection { + x: number + y: number + width: number + height: number + area: number +} + +export interface OverlapItem { + category: OverlapCategory + severity: OverlapSeverity + message: string + suggestion: string + area: number + ratio: number + nodeA: OverlapNodeSummary + nodeB: OverlapNodeSummary + intersection: OverlapIntersection +} + +export interface AnalyzeOverlapsArgs { + scope?: OverlapScope + category?: string + severity?: OverlapSeverity + min_area?: number + min_ratio?: number + include_hidden?: boolean + include_locked?: boolean + include_absolute?: boolean + limit?: number + /** Page name fallback; used only when `page_id` is not supplied. */ + page?: string + /** Stable page ID; takes precedence over `page`. */ + page_id?: string + type?: string +} + +export interface AnalyzeOverlapsSummary { + totalNodes: number + analyzedNodes: number + overlapCount: number + byCategory: Record + bySeverity: Record +} + +export interface AnalyzeOverlapsResult { + overlaps: OverlapItem[] + summary: AnalyzeOverlapsSummary +} + +function buildBoundsCache( + candidates: SceneNode[], + graph: SceneGraph +): { boundsCache: Map; entries: BoundsEntry[] } { + const boundsCache = new Map() + const entries: BoundsEntry[] = [] + + for (const node of candidates) { + const cached = boundsCache.get(node.id) + if (cached) { + entries.push(cached) + continue + } + const computed = computeNodeBounds(node, graph) + if (computed.area <= 0) continue + const entry: BoundsEntry = { node, ...computed } + boundsCache.set(node.id, entry) + entries.push(entry) + } + + return { boundsCache, entries } +} + +function collectParentOverflows( + candidates: SceneNode[], + graph: SceneGraph, + boundsCache: Map, + scope: OverlapScope, + minArea: number, + minRatio: number, + categoryFilter: OverlapCategory[] | undefined, + severityFilter: OverlapSeverity | undefined +): OverlapItem[] { + const overlaps: OverlapItem[] = [] + + for (const child of candidates) { + if (!child.parentId) continue + const parent = graph.getNode(child.parentId) + if (!parent || parent.type === 'CANVAS') continue + const childEntry = boundsCache.get(child.id) + if (!childEntry || childEntry.area <= 0) continue + let parentEntry = boundsCache.get(parent.id) + if (!parentEntry) { + const computed = computeNodeBounds(parent, graph) + if (computed.area <= 0) continue + parentEntry = { node: parent, ...computed } + boundsCache.set(parent.id, parentEntry) + } + const item = buildParentOverflowResult(child, childEntry.bounds, parent, parentEntry.bounds) + if ( + item && + matchesParentOverflowScope(scope) && + passesThresholds(item, minArea, minRatio, categoryFilter, severityFilter) + ) { + overlaps.push(item) + } + } + + return overlaps +} + +function collectSiblingOverlaps( + entries: BoundsEntry[], + graph: SceneGraph, + scope: OverlapScope, + minArea: number, + minRatio: number, + categoryFilter: OverlapCategory[] | undefined, + severityFilter: OverlapSeverity | undefined +): OverlapItem[] { + const overlaps: OverlapItem[] = [] + entries.sort((a, b) => a.bounds.minX - b.bounds.minX) + + for (let i = 0; i < entries.length; i++) { + const entryA = entries[i] + if (entryA.area <= 0) continue + const maxX = entryA.bounds.maxX + for (let j = i + 1; j < entries.length; j++) { + const entryB = entries[j] + if (entryB.bounds.minX > maxX) break + if (entryB.bounds.maxY <= entryA.bounds.minY || entryB.bounds.minY >= entryA.bounds.maxY) { + continue + } + + const rel = pairRelationship(entryA.node, entryB.node, graph) + if (rel.ancestor !== 'neither') continue + if (!matchesScope(rel, scope)) continue + + const item = buildSiblingOverlapResult( + entryA.node, + entryA.bounds, + entryB.node, + entryB.bounds, + graph + ) + if (item && passesThresholds(item, minArea, minRatio, categoryFilter, severityFilter)) { + overlaps.push(item) + } + } + } + + return overlaps +} + +function emptyByCategory(): Record { + return { + 'sibling-overlap': 0, + 'parent-overflow': 0, + overlay: 0 + } +} + +function emptyBySeverity(): Record { + return { + critical: 0, + major: 0, + minor: 0, + info: 0 + } +} + +/** + * Compute overlap findings for a scoped subset of the graph. + * Page, scope, type, and visibility filtering is applied via `args`. + * + * Heuristics implemented: + * - `sibling-overlap`: two non-ancestor nodes visually intersect. + * - `parent-overflow`: a child protrudes from a non-clipping parent. + * - `overlay`: a large node covers a much smaller sibling (modal/backdrop pattern). + * + * Filters: + * - hidden and locked nodes are skipped unless explicitly included + * - absolutely-positioned nodes are skipped unless explicitly included + * - ancestor/descendant pairs are never emitted as pair overlaps + */ +export function computeOverlaps( + graph: SceneGraph, + args: AnalyzeOverlapsArgs = {} +): AnalyzeOverlapsResult { + const scope = toScope(args.scope) ?? 'all' + const categoryFilter = parseCategoryFilter(args.category) + const severityFilter = parseSeverity(args.severity) + const minArea = Math.max(0, Number.isFinite(Number(args.min_area)) ? Number(args.min_area) : 0) + const minRatio = Math.max( + 0, + Math.min(1, Number.isFinite(Number(args.min_ratio)) ? Number(args.min_ratio) : 0) + ) + + const explicitPageName = args.page?.trim() + const explicitPageId = args.page_id?.trim() + let resolvedPageId: string | undefined + if (explicitPageId) { + resolvedPageId = explicitPageId + } else if (explicitPageName) { + resolvedPageId = findPageIdByName(graph, explicitPageName) + } else { + resolvedPageId = graph.getPages()[0]?.id + } + + if (!resolvedPageId) { + return { + overlaps: [], + summary: { + totalNodes: 0, + analyzedNodes: 0, + overlapCount: 0, + byCategory: emptyByCategory(), + bySeverity: emptyBySeverity() + } + } + } + + const resolvedArgs: AnalyzeOverlapsArgs = { ...args, page_id: resolvedPageId } + + const { candidates, totalNodes, analyzedNodes } = filterNodes(graph, resolvedArgs) + const { boundsCache, entries } = buildBoundsCache(candidates, graph) + + const overlaps = [ + ...collectParentOverflows( + candidates, + graph, + boundsCache, + scope, + minArea, + minRatio, + categoryFilter, + severityFilter + ), + ...collectSiblingOverlaps( + entries, + graph, + scope, + minArea, + minRatio, + categoryFilter, + severityFilter + ) + ] + + const sorted = orderBy( + overlaps, + [(o) => scoredSeverity(o.severity), (o) => o.area], + ['desc', 'desc'] + ) + + const limit = Math.max(0, Number.isFinite(Number(args.limit)) ? Number(args.limit) : 100) + const trimmed = limit > 0 ? sorted.slice(0, limit) : sorted + + const byCategory = emptyByCategory() + const bySeverity = emptyBySeverity() + + for (const item of sorted) { + byCategory[item.category]++ + bySeverity[item.severity]++ + } + + return { + overlaps: trimmed, + summary: { + totalNodes, + analyzedNodes, + overlapCount: sorted.length, + byCategory, + bySeverity + } + } +} + +export const analyzeOverlaps = defineTool({ + name: 'analyze_overlaps', + description: + 'Detect visual overlaps and layout overflows across the current page. Useful for finding content that covers footers, text that bleeds outside frames, and accidental sibling overlaps.', + params: { + scope: { + type: 'string', + description: + 'Which pairs to inspect: all, same-parent, cross-parent, top-level, inside-parent (default: all)', + enum: ['all', 'same-parent', 'cross-parent', 'top-level', 'inside-parent'], + default: 'all' + }, + category: { + type: 'string', + description: + 'Comma-separated categories: sibling-overlap, parent-overflow, overlay (default: all)' + }, + severity: { + type: 'string', + description: 'Minimum severity to include: critical, major, minor, info (default: info)', + enum: ['critical', 'major', 'minor', 'info'], + default: 'info' + }, + min_area: { + type: 'number', + description: 'Minimum overlap area in square pixels (default: 0)' + }, + min_ratio: { + type: 'number', + description: 'Minimum overlap ratio relative to the smaller node, 0.0–1.0 (default: 0)' + }, + include_hidden: { + type: 'boolean', + description: 'Include hidden nodes in the analysis' + }, + include_locked: { + type: 'boolean', + description: 'Include locked nodes in the analysis' + }, + include_absolute: { + type: 'boolean', + description: 'Include absolutely-positioned nodes in the analysis' + }, + page: { + type: 'string', + description: 'Limit analysis to nodes on the named page' + }, + page_id: { + type: 'string', + description: + 'Limit analysis to nodes on the page with this stable ID (takes precedence over page)' + }, + type: { + type: 'string', + description: 'Comma-separated node types to analyze, e.g. FRAME,TEXT' + }, + limit: { + type: 'number', + description: 'Maximum overlap findings to return (default: 100)', + default: 100 + } + }, + execute: (figma: FigmaAPI, args) => { + const page_id = args.page_id ?? (args.page ? undefined : figma.currentPageId) + return computeOverlaps(figma.graph, { ...(args as AnalyzeOverlapsArgs), page_id }) + } +}) diff --git a/packages/core/src/tools/analyze/overlaps/params.ts b/packages/core/src/tools/analyze/overlaps/params.ts new file mode 100644 index 000000000..f1844c5c1 --- /dev/null +++ b/packages/core/src/tools/analyze/overlaps/params.ts @@ -0,0 +1,47 @@ +import type { OverlapCategory, OverlapScope, OverlapSeverity } from './index' + +export const VALID_OVERLAP_SCOPES: readonly OverlapScope[] = [ + 'all', + 'same-parent', + 'cross-parent', + 'top-level', + 'inside-parent' +] + +export const VALID_OVERLAP_CATEGORIES: readonly OverlapCategory[] = [ + 'sibling-overlap', + 'parent-overflow', + 'overlay' +] + +export const VALID_OVERLAP_SEVERITIES: readonly OverlapSeverity[] = [ + 'critical', + 'major', + 'minor', + 'info' +] + +export function parseOverlapScope(raw: string | undefined): OverlapScope | undefined { + if (!raw) return undefined + const normalized = raw.toLowerCase() + return VALID_OVERLAP_SCOPES.find((scope) => scope === normalized) +} + +export function parseOverlapCategories(raw: string | undefined): OverlapCategory[] | undefined { + if (!raw) return undefined + const values = raw + .split(',') + .map((v) => v.trim().toLowerCase()) + .filter((v) => v.length > 0) + if (values.length === 0) return undefined + const categories = values.filter((v): v is OverlapCategory => + VALID_OVERLAP_CATEGORIES.includes(v as OverlapCategory) + ) + return categories.length > 0 ? categories : undefined +} + +export function parseOverlapSeverity(raw: string | undefined): OverlapSeverity | undefined { + if (!raw) return undefined + const normalized = raw.toLowerCase() + return VALID_OVERLAP_SEVERITIES.find((severity) => severity === normalized) +} diff --git a/packages/core/src/tools/index.ts b/packages/core/src/tools/index.ts index 9d5d7808b..fe30d814e 100644 --- a/packages/core/src/tools/index.ts +++ b/packages/core/src/tools/index.ts @@ -6,5 +6,13 @@ export type { ToolDef, ParamDef, ParamType } from './schema' export { toolsToAI, buildDebugLog } from './ai-adapter' export type { ToolLogEntry, ToolDebugLog, AIAdapterOptions, StepBudget } from './ai-adapter' export { calcClusterConfidence, wrapEvalCode } from './analyze' +export { + VALID_OVERLAP_CATEGORIES, + VALID_OVERLAP_SCOPES, + VALID_OVERLAP_SEVERITIES, + parseOverlapCategories, + parseOverlapScope, + parseOverlapSeverity +} from './analyze/overlaps/params' export { setPexelsApiKey, setUnsplashAccessKey } from './stock-photo' export { importSvg } from './create' diff --git a/packages/core/src/tools/registry-extended.ts b/packages/core/src/tools/registry-extended.ts index fd0435bd8..4668cb9e5 100644 --- a/packages/core/src/tools/registry-extended.ts +++ b/packages/core/src/tools/registry-extended.ts @@ -1,6 +1,7 @@ import { analyzeClusters, analyzeColors, + analyzeOverlaps, analyzeSpacing, analyzeTypography, diffCreate, @@ -185,6 +186,7 @@ export const EXTENDED_TOOLS: ToolDef[] = [ analyzeTypography, analyzeSpacing, analyzeClusters, + analyzeOverlaps, diffCreate, diffShow, // Codegen diff --git a/tests/engine/cli/eval.test.ts b/tests/engine/cli/eval.test.ts index 95cc812da..e26104d9a 100644 --- a/tests/engine/cli/eval.test.ts +++ b/tests/engine/cli/eval.test.ts @@ -3,9 +3,11 @@ import { randomUUID } from 'node:crypto' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { cliSourcePath, repoPath } from '#tests/helpers/paths' +import { cliSourcePath, repoPath, requireBuiltWorkspacePackages } from '#tests/helpers/paths' import { heavy } from '#tests/helpers/test-utils' +requireBuiltWorkspacePackages() + setDefaultTimeout(30_000) const CLI = cliSourcePath('index.ts') diff --git a/tests/engine/cli/overlaps.test.ts b/tests/engine/cli/overlaps.test.ts new file mode 100644 index 000000000..171d16a6e --- /dev/null +++ b/tests/engine/cli/overlaps.test.ts @@ -0,0 +1,162 @@ +import { expect, setDefaultTimeout, test } from 'bun:test' +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { SceneGraph } from '@open-pencil/core' +import { exportFigFile } from '@open-pencil/core/io/formats/fig' + +import { cliSourcePath, repoPath, requireBuiltWorkspacePackages } from '#tests/helpers/paths' +import { heavy } from '#tests/helpers/test-utils' + +requireBuiltWorkspacePackages() + +setDefaultTimeout(30_000) + +const CLI = cliSourcePath('index.ts') +const FIXTURE = repoPath('tests/fixtures/gold-preview.fig') + +async function makeTwoPageFixture(): Promise { + const graph = new SceneGraph() + const page1 = graph.getPages()[0].id + const page2 = graph.addPage('Page 2') + + graph.createNode('RECTANGLE', page1, { name: 'A', x: 0, y: 0, width: 100, height: 100 }) + graph.createNode('RECTANGLE', page1, { name: 'B', x: 50, y: 50, width: 100, height: 100 }) + graph.createNode('RECTANGLE', page2.id, { name: 'C', x: 0, y: 0, width: 100, height: 100 }) + graph.createNode('RECTANGLE', page2.id, { name: 'D', x: 50, y: 50, width: 100, height: 100 }) + + const data = await exportFigFile(graph) + const dir = mkdtempSync(join(tmpdir(), 'overlap-cli-')) + const file = join(dir, 'two-pages.fig') + writeFileSync(file, data) + return file +} + +async function run(args: string[]): Promise<{ stdout: string; stderr: string; exitCode: number }> { + const proc = Bun.spawn(['bun', CLI, ...args], { + stdout: 'pipe', + stderr: 'pipe' + }) + const [stdout, stderr] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text() + ]) + const exitCode = await proc.exited + return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode } +} + +heavy('analyze overlaps CLI', () => { + test('--json returns valid overlap data', async () => { + const { stdout, exitCode } = await run(['analyze', 'overlaps', FIXTURE, '--json']) + expect(exitCode).toBe(0) + const data = JSON.parse(stdout) + expect(data.overlaps).toBeArray() + expect(typeof data.summary.totalNodes).toBe('number') + expect(typeof data.summary.analyzedNodes).toBe('number') + expect(typeof data.summary.overlapCount).toBe('number') + }) + + test('human-readable mode prints a summary', async () => { + const { stdout, exitCode } = await run(['analyze', 'overlaps', FIXTURE]) + expect(exitCode).toBe(0) + expect(stdout.length).toBeGreaterThan(0) + expect(stdout).toContain('analyzed nodes') + }) + + test('invalid scope exits with error', async () => { + const { exitCode, stderr } = await run(['analyze', 'overlaps', FIXTURE, '--scope', 'invalid']) + expect(exitCode).not.toBe(0) + expect(stderr).toContain('Invalid scope') + }) + + test('invalid severity exits with error', async () => { + const { exitCode, stderr } = await run([ + 'analyze', + 'overlaps', + FIXTURE, + '--severity', + 'invalid' + ]) + expect(exitCode).not.toBe(0) + expect(stderr).toContain('Invalid severity') + }) + + test('invalid category exits with error', async () => { + const { exitCode, stderr } = await run(['analyze', 'overlaps', FIXTURE, '--category', 'bogus']) + expect(exitCode).not.toBe(0) + expect(stderr).toContain('Invalid categor') + }) + + test('invalid --min-area exits with error', async () => { + const { exitCode, stderr } = await run(['analyze', 'overlaps', FIXTURE, '--min-area', 'abc']) + expect(exitCode).not.toBe(0) + expect(stderr).toContain('--min-area') + }) + + test('invalid --limit exits with error', async () => { + const { exitCode, stderr } = await run(['analyze', 'overlaps', FIXTURE, '--limit', '0']) + expect(exitCode).not.toBe(0) + expect(stderr).toContain('--limit') + }) + + test('--category filters results', async () => { + const { stdout, exitCode } = await run([ + 'analyze', + 'overlaps', + FIXTURE, + '--json', + '--category', + 'parent-overflow' + ]) + expect(exitCode).toBe(0) + const data = JSON.parse(stdout) + expect(data.overlaps.every((o: { category: string }) => o.category === 'parent-overflow')).toBe( + true + ) + }) + + test('scope argument is case-insensitive', async () => { + const { stdout, exitCode } = await run([ + 'analyze', + 'overlaps', + FIXTURE, + '--json', + '--scope', + 'SAME-PARENT' + ]) + expect(exitCode).toBe(0) + const data = JSON.parse(stdout) + expect(data.overlaps).toBeArray() + expect(typeof data.summary.totalNodes).toBe('number') + }) + + test('default mode scopes analysis to the first page only', async () => { + const fixture = await makeTwoPageFixture() + const { stdout, exitCode } = await run(['analyze', 'overlaps', fixture, '--json']) + expect(exitCode).toBe(0) + const data = JSON.parse(stdout) + expect(data.summary.overlapCount).toBe(1) + expect(data.summary.totalNodes).toBe(2) + expect(data.overlaps[0].nodeA.name).toBe('A') + expect(data.overlaps[0].nodeB.name).toBe('B') + }) + + test('--page scopes analysis to the named page', async () => { + const fixture = await makeTwoPageFixture() + const { stdout, exitCode } = await run([ + 'analyze', + 'overlaps', + fixture, + '--json', + '--page', + 'Page 2' + ]) + expect(exitCode).toBe(0) + const data = JSON.parse(stdout) + expect(data.summary.overlapCount).toBe(1) + expect(data.summary.totalNodes).toBe(2) + expect(data.overlaps[0].nodeA.name).toBe('C') + expect(data.overlaps[0].nodeB.name).toBe('D') + }) +}) diff --git a/tests/engine/tools/analyze/overlaps/bounds.test.ts b/tests/engine/tools/analyze/overlaps/bounds.test.ts new file mode 100644 index 000000000..5430f6003 --- /dev/null +++ b/tests/engine/tools/analyze/overlaps/bounds.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, test } from 'bun:test' + +import { SceneGraph, type Rect } from '@open-pencil/core' +import { computeOverlaps } from '@open-pencil/core/tools/analyze/overlaps' + +import { frame, pageId, rect } from './helpers' + +describe('analyze overlaps visible bounds', () => { + test('large background below a small badge is not reported as overlay', () => { + const graph = new SceneGraph() + const page = pageId(graph) + rect(graph, 'Backdrop', page, 0, 0, 100, 100) + rect(graph, 'Badge', page, 10, 10, 10, 10) + + const result = computeOverlaps(graph) + expect(result.overlaps.some((o) => o.category === 'overlay')).toBe(false) + expect(result.overlaps.some((o) => o.category === 'sibling-overlap')).toBe(true) + }) + + test('text decoration extends visual bounds below raw height', () => { + const graph = new SceneGraph() + const page = pageId(graph) + const t = graph.createNode('TEXT', page, { + name: 'Underlined', + x: 0, + y: 0, + width: 100, + height: 10, + text: 'Underlined', + fontSize: 14, + textDecoration: 'UNDERLINE' + }) + rect(graph, 'Below', page, 0, 11, 100, 10) + + const result = computeOverlaps(graph) + expect(result.summary.overlapCount).toBeGreaterThan(0) + expect(result.overlaps.some((o) => o.nodeA.id === t.id || o.nodeB.id === t.id)).toBe(true) + }) + + test('vector geometry expands bounds beyond width and height', () => { + const graph = new SceneGraph() + const page = pageId(graph) + + function rectGeometryBlob(local: Rect) { + const parts = [ + { cmd: 1, x: local.x, y: local.y }, + { cmd: 2, x: local.x + local.width, y: local.y }, + { cmd: 2, x: local.x + local.width, y: local.y + local.height }, + { cmd: 2, x: local.x, y: local.y + local.height }, + { cmd: 0 } + ] + let byteLength = 0 + for (const part of parts) { + byteLength += 1 + if (part.x !== undefined) byteLength += 8 + } + const bytes = new Uint8Array(byteLength) + const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) + let offset = 0 + for (const part of parts) { + bytes[offset++] = part.cmd + if (part.x !== undefined) { + dv.setFloat32(offset, part.x, true) + dv.setFloat32(offset + 4, part.y as number, true) + offset += 8 + } + } + return bytes + } + + const v = graph.createNode('VECTOR', page, { + name: 'WideVector', + x: 0, + y: 0, + width: 10, + height: 10, + fillGeometry: [ + { + commandsBlob: rectGeometryBlob({ x: 0, y: 0, width: 200, height: 200 }), + windingRule: 'NONZERO' + } + ] + }) + rect(graph, 'Far', page, 150, 150, 50, 50) + + const result = computeOverlaps(graph) + expect(result.summary.overlapCount).toBeGreaterThan(0) + expect(result.overlaps.some((o) => o.nodeA.id === v.id || o.nodeB.id === v.id)).toBe(true) + }) + + test('ancestor clipping prevents false overlaps across the clip boundary', () => { + const graph = new SceneGraph() + const page = pageId(graph) + + const clipFrame = frame(graph, 'ClipFrame', page, 0, 0, 50, 50, true) + rect(graph, 'ClippedChild', clipFrame.id, 40, 0, 20, 20) + rect(graph, 'Outside', page, 50, 0, 20, 20) + + const clipped = computeOverlaps(graph, { category: 'sibling-overlap' }) + expect(clipped.summary.overlapCount).toBe(0) + + const noClipFrame = frame(graph, 'NoClipFrame', page, 200, 0, 50, 50, false) + rect(graph, 'UnclippedChild', noClipFrame.id, 40, 0, 20, 20) + rect(graph, 'Outside2', page, 250, 0, 20, 20) + + const unclipped = computeOverlaps(graph, { category: 'sibling-overlap' }) + expect(unclipped.summary.overlapCount).toBe(1) + }) + + test('rotated clipping frame prevents false sibling overlap', () => { + const graph = new SceneGraph() + const page = pageId(graph) + + // A 90°-rotated clipping frame. Its local space [0,100]x[0,100] maps to + // canvas [0,100]x[0,100] (a square rotated 90° around its center is the + // same square). But the OLD axis-aligned clip used the rotated origin + // (100,0) and produced a wrong clip rectangle [100,200]x[0,100]. + const clipFrame = frame(graph, 'RotatedClip', page, 0, 0, 100, 100, true) + graph.updateNode(clipFrame.id, { rotation: 90 }) + + // Child at local (10,10,20,20). After 90° rotation around the frame + // center (50,50), the child lands at canvas AABB [70,90]x[10,30]. + rect(graph, 'Child', clipFrame.id, 10, 10, 20, 20) + + // Outside node starting exactly at the clip frame's right boundary. + // With the BUGGY code the child's clipped bounds were [100,110]x[10,30] + // (wrong), producing a false overlap with [100,120]x[10,30]. + // With the fix the child's clipped bounds are [70,90]x[10,30] (correct), + // so there is no overlap. + rect(graph, 'Outside', page, 100, 10, 20, 20) + + const result = computeOverlaps(graph, { category: 'sibling-overlap' }) + expect(result.summary.overlapCount).toBe(0) + }) + + test('child of rotated non-clipping parent is detected at correct position', () => { + const graph = new SceneGraph() + const page = pageId(graph) + + // Non-clipping parent rotated 90°. A child at local (10,10,20,20) + // maps to canvas AABB [70,90]x[10,30] after rotation. A second node + // placed at canvas (72,12,10,10) should overlap — proving the bounds + // are computed from the world matrix, not from the rotated origin. + const parent = frame(graph, 'RotatedParent', page, 0, 0, 100, 100, false) + graph.updateNode(parent.id, { rotation: 90 }) + const child = rect(graph, 'Child', parent.id, 10, 10, 20, 20) + rect(graph, 'OverlapTarget', page, 72, 12, 10, 10) + + const result = computeOverlaps(graph, { category: 'sibling-overlap' }) + expect(result.summary.overlapCount).toBeGreaterThan(0) + expect(result.overlaps.some((o) => o.nodeA.id === child.id || o.nodeB.id === child.id)).toBe( + true + ) + }) + + test('rotated clipping frame still clips children that protrude', () => { + const graph = new SceneGraph() + const page = pageId(graph) + + // 90°-rotated clipping frame at (0,0,100,100). Its canvas-space clip is + // [0,100]x[0,100] (a square rotated 90° around its center is the same + // square). The OLD axis-aligned clip used the rotated origin (100,0) and + // produced a wrong clip rectangle [100,200]x[0,100]. + const clipFrame = frame(graph, 'RotatedClip90', page, 0, 0, 100, 100, true) + graph.updateNode(clipFrame.id, { rotation: 90 }) + + // Child at local (10,10,80,80) — a large child whose AABB (after 90° + // rotation) is [10,90]x[10,90], entirely inside the [0,100]x[0,100] clip. + rect(graph, 'Center', clipFrame.id, 10, 10, 80, 80) + + // Outside node at (110,10,20,20) — its AABB [110,130]x[10,30] does NOT + // overlap the child's clipped AABB [10,90]x[10,90] and does NOT overlap + // the clip frame's AABB [0,100]x[0,100]. With the BUGGY code, the child's + // clipped bounds were [100,170]x[10,90] (wrong), which WOULD overlap + // [110,130]x[10,30] — a false positive. + rect(graph, 'Outside', page, 110, 10, 20, 20) + + const result = computeOverlaps(graph, { category: 'sibling-overlap' }) + expect(result.summary.overlapCount).toBe(0) + }) +}) diff --git a/tests/engine/tools/analyze/overlaps/helpers.ts b/tests/engine/tools/analyze/overlaps/helpers.ts new file mode 100644 index 000000000..5d2002469 --- /dev/null +++ b/tests/engine/tools/analyze/overlaps/helpers.ts @@ -0,0 +1,57 @@ +import type { SceneGraph } from '@open-pencil/core' + +export function pageId(graph: SceneGraph) { + return graph.getPages()[0].id +} + +export function rect( + graph: SceneGraph, + name: string, + parentId: string, + x = 0, + y = 0, + w = 50, + h = 50 +) { + return graph.createNode('RECTANGLE', parentId, { name, x, y, width: w, height: h }) +} + +export function frame( + graph: SceneGraph, + name: string, + parentId: string, + x = 0, + y = 0, + w = 100, + h = 100, + clipsContent = false +) { + return graph.createNode('FRAME', parentId, { + name, + x, + y, + width: w, + height: h, + clipsContent + }) +} + +export function text( + graph: SceneGraph, + name: string, + parentId: string, + x = 0, + y = 0, + w = 100, + h = 20 +) { + return graph.createNode('TEXT', parentId, { + name, + x, + y, + width: w, + height: h, + text: name, + fontSize: 14 + }) +} diff --git a/tests/engine/tools/analyze/overlaps/index.test.ts b/tests/engine/tools/analyze/overlaps/index.test.ts new file mode 100644 index 000000000..14acfb329 --- /dev/null +++ b/tests/engine/tools/analyze/overlaps/index.test.ts @@ -0,0 +1,420 @@ +import { describe, expect, test } from 'bun:test' + +import { FigmaAPI, SceneGraph } from '@open-pencil/core' +import { + analyzeOverlaps, + computeOverlaps, + type OverlapScope +} from '@open-pencil/core/tools/analyze/overlaps' + +import { frame, pageId, rect, text } from './helpers' + +describe('analyze overlaps', () => { + test('detects overlapping sibling rectangles', () => { + const graph = new SceneGraph() + const page = pageId(graph) + const parent = frame(graph, 'Parent', page, 0, 0, 200, 200) + rect(graph, 'A', parent.id, 0, 0, 100, 100) + rect(graph, 'B', parent.id, 50, 50, 100, 100) + + const result = computeOverlaps(graph, { scope: 'same-parent' }) + expect(result.summary.overlapCount).toBeGreaterThan(0) + expect(result.overlaps.some((o) => o.category === 'sibling-overlap')).toBe(true) + }) + + test('ignores separated rectangles', () => { + const graph = new SceneGraph() + const page = pageId(graph) + rect(graph, 'A', page, 0, 0, 50, 50) + rect(graph, 'B', page, 200, 200, 50, 50) + + const result = computeOverlaps(graph) + expect(result.summary.overlapCount).toBe(0) + }) + + test('skips hidden and locked nodes by default', () => { + const graph = new SceneGraph() + const page = pageId(graph) + rect(graph, 'A', page, 0, 0, 100, 100) + const hidden = rect(graph, 'Hidden', page, 50, 50, 100, 100) + graph.updateNode(hidden.id, { visible: false }) + + const result = computeOverlaps(graph) + expect(result.summary.overlapCount).toBe(0) + }) + + test('includes hidden nodes when include_hidden is true', () => { + const graph = new SceneGraph() + const page = pageId(graph) + rect(graph, 'A', page, 0, 0, 100, 100) + const hidden = rect(graph, 'Hidden', page, 50, 50, 100, 100) + graph.updateNode(hidden.id, { visible: false }) + + const result = computeOverlaps(graph, { include_hidden: true }) + expect(result.summary.overlapCount).toBeGreaterThan(0) + }) + + test('reports text that overflows a non-clipping frame', () => { + const graph = new SceneGraph() + const page = pageId(graph) + const parent = frame(graph, 'Frame', page, 0, 0, 80, 80) + text(graph, 'Wide', parent.id, 0, 0, 200, 20) + + const result = computeOverlaps(graph) + expect(result.overlaps.some((o) => o.category === 'parent-overflow')).toBe(true) + }) + + test('reports a child that is fully outside its non-clipping parent', () => { + const graph = new SceneGraph() + const page = pageId(graph) + const parent = frame(graph, 'Frame', page, 0, 0, 100, 100) + rect(graph, 'Stray', parent.id, 200, 0, 50, 50) + + const result = computeOverlaps(graph) + expect(result.overlaps).toHaveLength(1) + const overflow = result.overlaps[0] + expect(overflow.category).toBe('parent-overflow') + expect(overflow.area).toBe(2500) + expect(overflow.ratio).toBe(1) + }) + + test('does not report overflow when parent clips content', () => { + const graph = new SceneGraph() + const page = pageId(graph) + const parent = frame(graph, 'Frame', page, 0, 0, 80, 80, true) + text(graph, 'Wide', parent.id, 0, 0, 200, 20) + + const result = computeOverlaps(graph) + expect(result.overlaps).toHaveLength(0) + }) + + test('detects rotated overlap', () => { + const graph = new SceneGraph() + const page = pageId(graph) + rect(graph, 'A', page, 0, 0, 100, 20) + const rotated = rect(graph, 'B', page, 0, 0, 100, 20) + graph.updateNode(rotated.id, { rotation: 45 }) + + const result = computeOverlaps(graph) + expect(result.summary.overlapCount).toBeGreaterThan(0) + }) + + test('detects a suspicious overlay covering a smaller sibling', () => { + const graph = new SceneGraph() + const page = pageId(graph) + // Create the smaller node first so the later (and therefore visually on-top) larger node + // genuinely covers it. + rect(graph, 'Badge', page, 10, 10, 10, 10) + rect(graph, 'Backdrop', page, 0, 0, 100, 100) + + const result = computeOverlaps(graph) + expect(result.overlaps.some((o) => o.category === 'overlay')).toBe(true) + const overlay = result.overlaps.find((o) => o.category === 'overlay') + expect(overlay?.message).toContain('"Backdrop" appears to be an overlay covering "Badge"') + }) + + test('filters by node type', () => { + const graph = new SceneGraph() + const page = pageId(graph) + rect(graph, 'A', page, 0, 0, 100, 100) + const t = text(graph, 'B', page, 50, 50, 100, 100) + + const result = computeOverlaps(graph, { type: 'TEXT' }) + expect(result.overlaps.every((o) => o.nodeA.id === t.id || o.nodeB.id === t.id)).toBe(true) + }) + + test('respects min_area threshold', () => { + const graph = new SceneGraph() + const page = pageId(graph) + rect(graph, 'A', page, 0, 0, 100, 100) + rect(graph, 'B', page, 99, 99, 2, 2) + + const result = computeOverlaps(graph, { min_area: 100 }) + expect(result.summary.overlapCount).toBe(0) + }) + + test('does not flag ancestor-descendant overlap', () => { + const graph = new SceneGraph() + const page = pageId(graph) + const parent = frame(graph, 'Frame', page, 0, 0, 200, 200) + rect(graph, 'Child', parent.id, 0, 0, 100, 100) + + const result = computeOverlaps(graph) + expect(result.summary.overlapCount).toBe(0) + }) + + test('scope=same-parent only reports siblings sharing a parent', () => { + const graph = new SceneGraph() + const page = pageId(graph) + const parentA = frame(graph, 'ParentA', page, 0, 0, 200, 200) + const parentB = frame(graph, 'ParentB', page, 300, 0, 200, 200) + rect(graph, 'A1', parentA.id, 0, 0, 100, 100) + rect(graph, 'A2', parentA.id, 50, 50, 100, 100) + rect(graph, 'B1', parentB.id, 0, 0, 100, 100) + rect(graph, 'B2', parentB.id, 50, 50, 100, 100) + + const result = computeOverlaps(graph, { scope: 'same-parent', category: 'sibling-overlap' }) + expect(result.summary.overlapCount).toBe(2) + expect( + result.overlaps.every( + (o) => o.nodeA.parentId === o.nodeB.parentId && o.nodeA.parentId !== page + ) + ).toBe(true) + }) + + test('scope=cross-parent only reports nodes from different parents', () => { + const graph = new SceneGraph() + const page = pageId(graph) + const parentA = frame(graph, 'ParentA', page, 0, 0, 200, 200) + const parentB = frame(graph, 'ParentB', page, 300, 0, 200, 200) + rect(graph, 'A', parentA.id, 150, 0, 110, 100) + rect(graph, 'B', parentB.id, -60, 0, 110, 100) + + const result = computeOverlaps(graph, { scope: 'cross-parent', category: 'sibling-overlap' }) + expect(result.summary.overlapCount).toBe(1) + expect(result.overlaps[0].nodeA.parentId).not.toBe(result.overlaps[0].nodeB.parentId) + }) + + test('scope=top-level only reports direct page children', () => { + const graph = new SceneGraph() + const page = pageId(graph) + rect(graph, 'TopA', page, 0, 0, 100, 100) + rect(graph, 'TopB', page, 50, 50, 100, 100) + const parent = frame(graph, 'Parent', page, 300, 300, 200, 200) + rect(graph, 'InnerA', parent.id, 0, 0, 100, 100) + rect(graph, 'InnerB', parent.id, 50, 50, 100, 100) + + const result = computeOverlaps(graph, { scope: 'top-level' }) + expect(result.summary.overlapCount).toBe(1) + expect(result.overlaps[0].nodeA.parentId).toBe(page) + expect(result.overlaps[0].nodeB.parentId).toBe(page) + }) + + test('scope=inside-parent only reports children inside a non-page parent', () => { + const graph = new SceneGraph() + const page = pageId(graph) + rect(graph, 'TopA', page, 0, 0, 100, 100) + rect(graph, 'TopB', page, 50, 50, 100, 100) + const parent = frame(graph, 'Parent', page, 300, 300, 200, 200) + rect(graph, 'InnerA', parent.id, 0, 0, 100, 100) + rect(graph, 'InnerB', parent.id, 50, 50, 100, 100) + + const result = computeOverlaps(graph, { scope: 'inside-parent' }) + expect(result.summary.overlapCount).toBe(1) + expect(result.overlaps[0].nodeA.parentId).toBe(parent.id) + expect(result.overlaps[0].nodeB.parentId).toBe(parent.id) + }) + + test('respects min_ratio threshold', () => { + const graph = new SceneGraph() + const page = pageId(graph) + const parent = frame(graph, 'Parent', page, 0, 0, 200, 200) + rect(graph, 'A', parent.id, 0, 0, 100, 100) + rect(graph, 'B', parent.id, 50, 50, 100, 100) + + const excluded = computeOverlaps(graph, { scope: 'same-parent', min_ratio: 0.5 }) + expect(excluded.overlaps).toHaveLength(0) + + const included = computeOverlaps(graph, { scope: 'same-parent', min_ratio: 0.1 }) + expect(included.overlaps.length).toBeGreaterThan(0) + }) + + test('excludes locked nodes by default', () => { + const graph = new SceneGraph() + const page = pageId(graph) + const locked = rect(graph, 'Locked', page, 0, 0, 100, 100) + graph.updateNode(locked.id, { locked: true }) + rect(graph, 'A', page, 50, 50, 100, 100) + + const result = computeOverlaps(graph) + expect( + result.overlaps.some((o) => o.nodeA.name === 'Locked' || o.nodeB.name === 'Locked') + ).toBe(false) + }) + + test('includes locked nodes when include_locked is true', () => { + const graph = new SceneGraph() + const page = pageId(graph) + const locked = rect(graph, 'Locked', page, 0, 0, 100, 100) + graph.updateNode(locked.id, { locked: true }) + rect(graph, 'A', page, 50, 50, 100, 100) + + const result = computeOverlaps(graph, { include_locked: true }) + expect( + result.overlaps.some((o) => o.nodeA.name === 'Locked' || o.nodeB.name === 'Locked') + ).toBe(true) + }) + + test('excludes absolutely-positioned nodes by default', () => { + const graph = new SceneGraph() + const page = pageId(graph) + const absolute = rect(graph, 'Absolute', page, 0, 0, 100, 100) + graph.updateNode(absolute.id, { layoutPositioning: 'ABSOLUTE' }) + rect(graph, 'A', page, 50, 50, 100, 100) + + const result = computeOverlaps(graph) + expect( + result.overlaps.some((o) => o.nodeA.name === 'Absolute' || o.nodeB.name === 'Absolute') + ).toBe(false) + }) + + test('includes absolutely-positioned nodes when include_absolute is true', () => { + const graph = new SceneGraph() + const page = pageId(graph) + const absolute = rect(graph, 'Absolute', page, 0, 0, 100, 100) + graph.updateNode(absolute.id, { layoutPositioning: 'ABSOLUTE' }) + rect(graph, 'A', page, 50, 50, 100, 100) + + const result = computeOverlaps(graph, { include_absolute: true }) + expect( + result.overlaps.some((o) => o.nodeA.name === 'Absolute' || o.nodeB.name === 'Absolute') + ).toBe(true) + }) + + test('filters results by page name', () => { + const graph = new SceneGraph() + const page1 = pageId(graph) + const page2 = graph.addPage('Page 2') + + const a1 = rect(graph, 'A1', page1, 0, 0, 100, 100) + const a2 = rect(graph, 'A2', page1, 50, 50, 100, 100) + const b1 = rect(graph, 'B1', page2.id, 0, 0, 100, 100) + const b2 = rect(graph, 'B2', page2.id, 50, 50, 100, 100) + + const page1Result = computeOverlaps(graph, { page: 'Page 1' }) + expect(page1Result.overlaps.length).toBeGreaterThan(0) + expect( + page1Result.overlaps.every( + (o) => + (o.nodeA.id === a1.id || o.nodeA.id === a2.id) && + (o.nodeB.id === a1.id || o.nodeB.id === a2.id) + ) + ).toBe(true) + + const page2Result = computeOverlaps(graph, { page: 'Page 2' }) + expect(page2Result.overlaps.length).toBeGreaterThan(0) + expect( + page2Result.overlaps.every( + (o) => + (o.nodeA.id === b1.id || o.nodeA.id === b2.id) && + (o.nodeB.id === b1.id || o.nodeB.id === b2.id) + ) + ).toBe(true) + + const missing = computeOverlaps(graph, { page: 'Missing' }) + expect(missing.overlaps).toHaveLength(0) + }) + + test('scope values are case-insensitive', () => { + const graph = new SceneGraph() + const page = pageId(graph) + rect(graph, 'A', page, 0, 0, 100, 100) + rect(graph, 'B', page, 50, 50, 100, 100) + + const result = computeOverlaps(graph, { scope: 'SAME-PARENT' as OverlapScope }) + expect(result.overlaps.length).toBeGreaterThan(0) + }) + + test('ToolDef defaults to current page and does not report overlaps on other pages', () => { + const graph = new SceneGraph() + const page2 = graph.addPage('Page 2') + + rect(graph, 'A', page2.id, 0, 0, 100, 100) + rect(graph, 'B', page2.id, 50, 50, 100, 100) + + const api = new FigmaAPI(graph) + const result = analyzeOverlaps.execute(api, {}) + expect(result.summary.overlapCount).toBe(0) + expect(result.overlaps).toHaveLength(0) + }) + + test('ToolDef respects explicit page argument', () => { + const graph = new SceneGraph() + const page2 = graph.addPage('Page 2') + + rect(graph, 'A', page2.id, 0, 0, 100, 100) + rect(graph, 'B', page2.id, 50, 50, 100, 100) + + const api = new FigmaAPI(graph) + const result = analyzeOverlaps.execute(api, { page: 'Page 2' }) + expect(result.summary.overlapCount).toBeGreaterThan(0) + }) + + test('scope=top-level excludes nested parent-overflow', () => { + const graph = new SceneGraph() + const page = pageId(graph) + const parent = frame(graph, 'Frame', page, 0, 0, 80, 80) + text(graph, 'Wide', parent.id, 0, 0, 200, 20) + + const all = computeOverlaps(graph) + expect(all.overlaps.some((o) => o.category === 'parent-overflow')).toBe(true) + + const topLevel = computeOverlaps(graph, { scope: 'top-level' }) + expect(topLevel.overlaps.some((o) => o.category === 'parent-overflow')).toBe(false) + }) + + test('scope=inside-parent includes nested parent-overflow', () => { + const graph = new SceneGraph() + const page = pageId(graph) + const parent = frame(graph, 'Frame', page, 0, 0, 80, 80) + text(graph, 'Wide', parent.id, 0, 0, 200, 20) + + const result = computeOverlaps(graph, { scope: 'inside-parent' }) + expect(result.overlaps.some((o) => o.category === 'parent-overflow')).toBe(true) + }) + + test('skips nodes inside a hidden ancestor by default', () => { + const graph = new SceneGraph() + const page = pageId(graph) + const hiddenParent = frame(graph, 'HiddenParent', page, 0, 0, 200, 200) + graph.updateNode(hiddenParent.id, { visible: false }) + rect(graph, 'A', hiddenParent.id, 0, 0, 100, 100) + rect(graph, 'B', hiddenParent.id, 50, 50, 100, 100) + + const result = computeOverlaps(graph) + expect(result.summary.overlapCount).toBe(0) + }) + + test('includes nodes inside a hidden ancestor when include_hidden is true', () => { + const graph = new SceneGraph() + const page = pageId(graph) + const hiddenParent = frame(graph, 'HiddenParent', page, 0, 0, 200, 200) + graph.updateNode(hiddenParent.id, { visible: false }) + rect(graph, 'A', hiddenParent.id, 0, 0, 100, 100) + rect(graph, 'B', hiddenParent.id, 50, 50, 100, 100) + + const result = computeOverlaps(graph, { include_hidden: true }) + expect(result.summary.overlapCount).toBeGreaterThan(0) + }) + + test('skips nodes inside a locked ancestor by default', () => { + const graph = new SceneGraph() + const page = pageId(graph) + const lockedParent = frame(graph, 'LockedParent', page, 0, 0, 200, 200) + graph.updateNode(lockedParent.id, { locked: true }) + rect(graph, 'A', lockedParent.id, 0, 0, 100, 100) + rect(graph, 'B', lockedParent.id, 50, 50, 100, 100) + + const result = computeOverlaps(graph) + expect(result.summary.overlapCount).toBe(0) + }) + + test('summary totals reflect full result set when a limit is applied', () => { + const graph = new SceneGraph() + const page = pageId(graph) + const parent = frame(graph, 'Frame', page, 0, 0, 200, 200) + + for (let i = 0; i < 4; i++) { + rect(graph, `R${i}`, parent.id, 0, 0, 100, 100) + } + + const result = computeOverlaps(graph, { scope: 'inside-parent', limit: 2 }) + expect(result.overlaps.length).toBe(2) + expect(result.summary.overlapCount).toBeGreaterThan(2) + + const totalByCategory = Object.values(result.summary.byCategory).reduce((a, b) => a + b, 0) + expect(totalByCategory).toBe(result.summary.overlapCount) + const totalBySeverity = Object.values(result.summary.bySeverity).reduce((a, b) => a + b, 0) + expect(totalBySeverity).toBe(result.summary.overlapCount) + }) +}) diff --git a/tests/engine/tools/analyze/overlaps/page-scope.test.ts b/tests/engine/tools/analyze/overlaps/page-scope.test.ts new file mode 100644 index 000000000..6e0b13b88 --- /dev/null +++ b/tests/engine/tools/analyze/overlaps/page-scope.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, test } from 'bun:test' + +import { FigmaAPI, SceneGraph } from '@open-pencil/core' +import { + analyzeOverlaps, + computeOverlaps, + findPageId +} from '@open-pencil/core/tools/analyze/overlaps' + +import { pageId, rect } from './helpers' + +describe('analyze overlaps page scope', () => { + test('default scope limits analysis to a single page', () => { + const graph = new SceneGraph() + const page1 = pageId(graph) + const page2 = graph.addPage('Page 2') + + rect(graph, 'A', page1, 0, 0, 100, 100) + rect(graph, 'B', page1, 50, 50, 100, 100) + rect(graph, 'C', page2.id, 0, 0, 100, 100) + rect(graph, 'D', page2.id, 50, 50, 100, 100) + + const result = computeOverlaps(graph) + expect(result.summary.overlapCount).toBe(1) + expect(result.overlaps).toHaveLength(1) + expect( + result.overlaps.every((o) => { + const nodeA = graph.getNode(o.nodeA.id) + const nodeB = graph.getNode(o.nodeB.id) + if (!nodeA || !nodeB) return false + return findPageId(graph, nodeA) === page1 && findPageId(graph, nodeB) === page1 + }) + ).toBe(true) + }) + + test('missing page name returns an empty result', () => { + const graph = new SceneGraph() + const page1 = pageId(graph) + const page2 = graph.addPage('Page 2') + + rect(graph, 'A', page1, 0, 0, 100, 100) + rect(graph, 'B', page1, 50, 50, 100, 100) + rect(graph, 'C', page2.id, 0, 0, 100, 100) + rect(graph, 'D', page2.id, 50, 50, 100, 100) + + const result = computeOverlaps(graph, { page: 'Missing' }) + expect(result.summary.totalNodes).toBe(0) + expect(result.summary.analyzedNodes).toBe(0) + expect(result.summary.overlapCount).toBe(0) + expect(result.overlaps).toHaveLength(0) + }) + + test('ToolDef currentPage default uses page ID so duplicate page names do not leak', () => { + const graph = new SceneGraph() + const page1 = pageId(graph) + const page2 = graph.addPage('Page 1') + + rect(graph, 'A', page2.id, 0, 0, 100, 100) + rect(graph, 'B', page2.id, 50, 50, 100, 100) + + const api = new FigmaAPI(graph) + expect(api.currentPageId).toBe(page1) + + const defaultResult = analyzeOverlaps.execute(api, {}) + expect(defaultResult.summary.overlapCount).toBe(0) + expect(defaultResult.overlaps).toHaveLength(0) + + api.currentPage = api.wrapNode(page2.id) + const currentResult = analyzeOverlaps.execute(api, {}) + expect(currentResult.summary.overlapCount).toBeGreaterThan(0) + }) + + test('page_id argument disambiguates duplicate page names', () => { + const graph = new SceneGraph() + const page1 = pageId(graph) + const page2 = graph.addPage('Page 1') + + rect(graph, 'A', page2.id, 0, 0, 100, 100) + rect(graph, 'B', page2.id, 50, 50, 100, 100) + + const byId = computeOverlaps(graph, { page_id: page2.id }) + expect(byId.summary.overlapCount).toBe(1) + + const byName = computeOverlaps(graph, { page: 'Page 1' }) + expect(byName.summary.overlapCount).toBe(0) + + const byFirstPage = computeOverlaps(graph, { page_id: page1 }) + expect(byFirstPage.summary.overlapCount).toBe(0) + }) +}) diff --git a/tests/helpers/paths.ts b/tests/helpers/paths.ts index cd33e27af..3c6d868b7 100644 --- a/tests/helpers/paths.ts +++ b/tests/helpers/paths.ts @@ -1,3 +1,4 @@ +import { existsSync } from 'node:fs' import { join } from 'node:path' const repoRoot = join(import.meta.dir, '..', '..') @@ -21,3 +22,12 @@ export function testPath(...segments: string[]): string { export function publicPath(...segments: string[]): string { return repoPath('public', ...segments) } + +export function requireBuiltWorkspacePackages(): void { + const coreDist = repoPath('packages/core/dist/index.js') + if (!existsSync(coreDist)) { + throw new Error( + 'CLI integration tests require built workspace packages. Run `bun run check` or `bun run --filter @open-pencil/core build` first.' + ) + } +}